diff --git a/.github/workflow-resource-files/seed-groups/csharp-model-seed-groups.json b/.github/workflow-resource-files/seed-groups/csharp-model-seed-groups.json index 77bc3a758338..a50f68b81c5e 100644 --- a/.github/workflow-resource-files/seed-groups/csharp-model-seed-groups.json +++ b/.github/workflow-resource-files/seed-groups/csharp-model-seed-groups.json @@ -150,4 +150,4 @@ "groupTotalTimeSeconds": 1456 } ] -} +} \ No newline at end of file diff --git a/.github/workflow-resource-files/seed-groups/csharp-sdk-seed-groups.json b/.github/workflow-resource-files/seed-groups/csharp-sdk-seed-groups.json index 730801cdddc4..789462762513 100644 --- a/.github/workflow-resource-files/seed-groups/csharp-sdk-seed-groups.json +++ b/.github/workflow-resource-files/seed-groups/csharp-sdk-seed-groups.json @@ -42,7 +42,8 @@ "file-upload", "circular-references", "circular-references-advanced", - "nullable-optional", + "nullable-optional:no-custom-config", + "nullable-optional:explicit-nullable-optional", "inferred-auth-implicit-no-expiry", "multi-url-environment-no-default", "no-environment", @@ -132,7 +133,8 @@ "websocket:with-websockets", "package-yml", "path-parameters:no-custom-config", - "required-nullable", + "required-nullable:no-custom-config", + "required-nullable:explicit-nullable-optional", "multi-url-environment:no-pascal-case-environments", "object", "error-property", @@ -164,7 +166,8 @@ "csharp-namespace-conflict", "streaming", "single-url-environment-no-default", - "nullable", + "nullable:no-custom-config", + "nullable:explicit-nullable-optional", "content-type", "query-parameters", "errors", @@ -174,4 +177,4 @@ "groupTotalTimeSeconds": 3359 } ] -} +} \ No newline at end of file diff --git a/docs-yml.schema.json b/docs-yml.schema.json index 1ef92a792dda..4b671d96bf44 100644 --- a/docs-yml.schema.json +++ b/docs-yml.schema.json @@ -3788,6 +3788,16 @@ "type": "null" } ] + }, + "exclude-apis": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] } }, "additionalProperties": false diff --git a/fern/apis/docs-yml/definition/docs.yml b/fern/apis/docs-yml/definition/docs.yml index 36c06de14178..4802b8af70cd 100644 --- a/fern/apis/docs-yml/definition/docs.yml +++ b/fern/apis/docs-yml/definition/docs.yml @@ -1662,6 +1662,11 @@ types: Custom styling instructions for AI-generated examples. When provided, these instructions will guide the AI in generating examples that match your preferred style, naming conventions, or domain-specific terminology. Limited to 500 characters. DEPRECATED: Use the top-level `ai-example-style-instructions` property instead. + exclude-apis: + type: optional + availability: pre-release + docs: | + Experimental flag to exclude API reference sections from documentation generation. When enabled, API reference content will be omitted from the generated documentation. PlaygroundSettings: properties: diff --git a/generators/csharp/base/src/AsIs.ts b/generators/csharp/base/src/AsIs.ts index 2c09a88a7f3f..8703778aab07 100644 --- a/generators/csharp/base/src/AsIs.ts +++ b/generators/csharp/base/src/AsIs.ts @@ -39,31 +39,22 @@ export const AsIsFiles = { QueryStringConverter: "QueryStringConverter.Template.cs", RawClient: "RawClient.Template.cs", StreamRequest: "StreamRequest.Template.cs", - WebSocketAsync: { - Events: { - Closed: "Async/Events/Closed.Template.cs", - Connected: "Async/Events/Connected.Template.cs", - Event: "Async/Events/Event.Template.cs" - }, - Exceptions: { - WebsocketException: "Async/Exceptions/WebsocketException.Template.cs" - }, - Models: { - Options: "Async/Models/Options.Template.cs", - DisconnectionInfo: "Async/Models/DisconnectionInfo.Template.cs", - DisconnectionType: "Async/Models/DisconnectionType.Template.cs", - ReconnectionInfo: "Async/Models/ReconnectionInfo.Template.cs", - ReconnectionType: "Async/Models/ReconnectionType.Template.cs" - }, - Threading: { - AsyncLock: "Async/Threading/AsyncLock.Template.cs" - }, - AsyncApi: "Async/AsyncApi.Template.cs", - ConnectionStatus: "Async/ConnectionStatus.Template.cs", - Query: "Async/Query.Template.cs", - RequestMessage: "Async/RequestMessage.Template.cs", - WebSocketConnection: "Async/WebSocketConnection.Template.cs", - WebSocketConnectionSending: "Async/WebSocketConnection.Sending.Template.cs" + WebSockets: { + AsyncLock: "WebSockets/AsyncLock.Template.cs", + Closed: "WebSockets/Closed.Template.cs", + Connected: "WebSockets/Connected.Template.cs", + ConnectionStatus: "WebSockets/ConnectionStatus.Template.cs", + DisconnectionInfo: "WebSockets/DisconnectionInfo.Template.cs", + DisconnectionType: "WebSockets/DisconnectionType.Template.cs", + Event: "WebSockets/Event.Template.cs", + Query: "WebSockets/Query.Template.cs", + ReconnectionInfo: "WebSockets/ReconnectionInfo.Template.cs", + ReconnectionType: "WebSockets/ReconnectionType.Template.cs", + RequestMessage: "WebSockets/RequestMessage.Template.cs", + WebSocketClient: "WebSockets/WebSocketClient.Template.cs", + WebSocketConnection: "WebSockets/WebSocketConnection.Template.cs", + WebSocketConnectionSending: "WebSockets/WebSocketConnection.Sending.Template.cs", + WebsocketException: "WebSockets/WebsocketException.Template.cs" }, Json: { AdditionalProperties: "AdditionalProperties.Template.cs", @@ -74,7 +65,10 @@ export const AsIsFiles = { EnumSerializer: "EnumSerializer.Template.cs", JsonAccessAttribute: "JsonAccessAttribute.Template.cs", JsonConfiguration: "JsonConfiguration.Template.cs", + Nullable: "NullableAttribute.Template.cs", OneOfSerializer: "OneOfSerializer.Template.cs", + Optional: "Optional.Template.cs", + OptionalAttribute: "OptionalAttribute.Template.cs", StringEnumSerializer: "StringEnumSerializer.Template.cs" }, Test: { @@ -94,6 +88,7 @@ export const AsIsFiles = { JsonElementComparer: "test/Utils/JsonElementComparer.Template.cs", NUnitExtensions: "test/Utils/NUnitExtensions.Template.cs", OneOfComparer: "test/Utils/OneOfComparer.Template.cs", + OptionalComparer: "test/Utils/OptionalComparer.Template.cs", ReadOnlyMemoryComparer: "test/Utils/ReadOnlyMemoryComparer.Template.cs" }, Pagination: [ diff --git a/generators/csharp/base/src/asIs/Async/Models/Options.Template.cs b/generators/csharp/base/src/asIs/Async/Models/Options.Template.cs deleted file mode 100644 index 58429001420d..000000000000 --- a/generators/csharp/base/src/asIs/Async/Models/Options.Template.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.ComponentModel; -using System.Runtime.CompilerServices; - -namespace <%= namespace%>.Async.Models; - -/// -/// Abstract base class for asynchronous API configuration options. -/// Provides common configuration properties for WebSocket-based API connections. -/// -public abstract class AsyncApiOptions -{ - /// - /// Gets or sets the base URL for the API connection. - /// - virtual public string BaseUrl { get; set; } = ""; -} diff --git a/generators/csharp/base/src/asIs/JsonConfiguration.Template.cs b/generators/csharp/base/src/asIs/JsonConfiguration.Template.cs index dae83b94fd07..1e61131da6b1 100644 --- a/generators/csharp/base/src/asIs/JsonConfiguration.Template.cs +++ b/generators/csharp/base/src/asIs/JsonConfiguration.Template.cs @@ -18,7 +18,8 @@ static JsonOptions() #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory() }, #if DEBUG WriteIndented = true, #endif @@ -27,76 +28,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - <% if (additionalProperties) { %> - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - <% } %> - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier,<% if (additionalProperties) { %> + HandleExtensionDataFields,<% } %> }, }, }; @@ -104,6 +38,144 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = property.PropertyType.IsGenericType && + property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes( + typeof(JsonAccessAttribute), + true + ) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes( + typeof(JsonIgnoreAttribute), + true + ) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + <% if (additionalProperties) { %> + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + <% } %> static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/generators/csharp/base/src/asIs/NullableAttribute.Template.cs b/generators/csharp/base/src/asIs/NullableAttribute.Template.cs new file mode 100644 index 000000000000..2e403f73b1a9 --- /dev/null +++ b/generators/csharp/base/src/asIs/NullableAttribute.Template.cs @@ -0,0 +1,20 @@ +namespace <%= namespace%>; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute +{ +} diff --git a/generators/csharp/base/src/asIs/Optional.Template.cs b/generators/csharp/base/src/asIs/Optional.Template.cs new file mode 100644 index 000000000000..e16411b030c4 --- /dev/null +++ b/generators/csharp/base/src/asIs/Optional.Template.cs @@ -0,0 +1,479 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace <%= namespace%>; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined + && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => + obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => + left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => + !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/generators/csharp/base/src/asIs/OptionalAttribute.Template.cs b/generators/csharp/base/src/asIs/OptionalAttribute.Template.cs new file mode 100644 index 000000000000..00ec3fdbcec9 --- /dev/null +++ b/generators/csharp/base/src/asIs/OptionalAttribute.Template.cs @@ -0,0 +1,19 @@ +namespace <%= namespace%>; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute +{ +} diff --git a/generators/csharp/base/src/asIs/RawClient.Template.cs b/generators/csharp/base/src/asIs/RawClient.Template.cs index 52f1ad7c6e41..77e2d06210cc 100644 --- a/generators/csharp/base/src/asIs/RawClient.Template.cs +++ b/generators/csharp/base/src/asIs/RawClient.Template.cs @@ -344,6 +344,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/generators/csharp/base/src/asIs/Async/Threading/AsyncLock.Template.cs b/generators/csharp/base/src/asIs/WebSockets/AsyncLock.Template.cs similarity index 97% rename from generators/csharp/base/src/asIs/Async/Threading/AsyncLock.Template.cs rename to generators/csharp/base/src/asIs/WebSockets/AsyncLock.Template.cs index c7fe10188c19..39b20e9ace78 100644 --- a/generators/csharp/base/src/asIs/Async/Threading/AsyncLock.Template.cs +++ b/generators/csharp/base/src/asIs/WebSockets/AsyncLock.Template.cs @@ -1,8 +1,8 @@ -// ReSharper disable All +// ReSharper disable All // #pragma warning disable // #pragma warning disable CS8600 // #pragma warning disable CS8619 -namespace <%= namespace%>.Async.Threading; +namespace <%= namespace%>.WebSockets; /// /// Provides a convenient wrapper around SemaphoreSlim that enables easy use of locking inside 'using' blocks. diff --git a/generators/csharp/base/src/asIs/Async/Events/Closed.Template.cs b/generators/csharp/base/src/asIs/WebSockets/Closed.Template.cs similarity index 90% rename from generators/csharp/base/src/asIs/Async/Events/Closed.Template.cs rename to generators/csharp/base/src/asIs/WebSockets/Closed.Template.cs index 6b17fcc9d0ec..71c0f14acbf2 100644 --- a/generators/csharp/base/src/asIs/Async/Events/Closed.Template.cs +++ b/generators/csharp/base/src/asIs/WebSockets/Closed.Template.cs @@ -1,4 +1,4 @@ -namespace <%= namespace%>.Async.Events; +namespace <%= namespace%>.WebSockets; /// /// Event arguments for when the connection with the async service is closed. diff --git a/generators/csharp/base/src/asIs/Async/Events/Connected.Template.cs b/generators/csharp/base/src/asIs/WebSockets/Connected.Template.cs similarity index 78% rename from generators/csharp/base/src/asIs/Async/Events/Connected.Template.cs rename to generators/csharp/base/src/asIs/WebSockets/Connected.Template.cs index 7a4abfc5bd09..6c5c5b7edb02 100644 --- a/generators/csharp/base/src/asIs/Async/Events/Connected.Template.cs +++ b/generators/csharp/base/src/asIs/WebSockets/Connected.Template.cs @@ -1,4 +1,4 @@ -namespace <%= namespace%>.Async.Events; +namespace <%= namespace%>.WebSockets; /// /// Event arguments for when the connection with the async service is established. diff --git a/generators/csharp/base/src/asIs/Async/ConnectionStatus.Template.cs b/generators/csharp/base/src/asIs/WebSockets/ConnectionStatus.Template.cs similarity index 94% rename from generators/csharp/base/src/asIs/Async/ConnectionStatus.Template.cs rename to generators/csharp/base/src/asIs/WebSockets/ConnectionStatus.Template.cs index 5c564f06fd10..e1664c893c6b 100644 --- a/generators/csharp/base/src/asIs/Async/ConnectionStatus.Template.cs +++ b/generators/csharp/base/src/asIs/WebSockets/ConnectionStatus.Template.cs @@ -1,4 +1,4 @@ -namespace <%= namespace%>.Async; +namespace <%= namespace%>.WebSockets; /// /// Represents the current state of an asynchronous connection. diff --git a/generators/csharp/base/src/asIs/Async/Models/DisconnectionInfo.Template.cs b/generators/csharp/base/src/asIs/WebSockets/DisconnectionInfo.Template.cs similarity index 97% rename from generators/csharp/base/src/asIs/Async/Models/DisconnectionInfo.Template.cs rename to generators/csharp/base/src/asIs/WebSockets/DisconnectionInfo.Template.cs index 4b8fe31733a2..c27b78fae84d 100644 --- a/generators/csharp/base/src/asIs/Async/Models/DisconnectionInfo.Template.cs +++ b/generators/csharp/base/src/asIs/WebSockets/DisconnectionInfo.Template.cs @@ -1,8 +1,8 @@ -// ReSharper disable All +// ReSharper disable All #pragma warning disable using System.Net.WebSockets; -namespace <%= namespace%>.Async.Models; +namespace <%= namespace%>.WebSockets; /// /// Contains information about a WebSocket disconnection event. diff --git a/generators/csharp/base/src/asIs/Async/Models/DisconnectionType.Template.cs b/generators/csharp/base/src/asIs/WebSockets/DisconnectionType.Template.cs similarity index 93% rename from generators/csharp/base/src/asIs/Async/Models/DisconnectionType.Template.cs rename to generators/csharp/base/src/asIs/WebSockets/DisconnectionType.Template.cs index c56a90b86d96..0015612fb04c 100644 --- a/generators/csharp/base/src/asIs/Async/Models/DisconnectionType.Template.cs +++ b/generators/csharp/base/src/asIs/WebSockets/DisconnectionType.Template.cs @@ -1,6 +1,6 @@ -// ReSharper disable All +// ReSharper disable All #pragma warning disable -namespace <%= namespace%>.Async.Models; +namespace <%= namespace%>.WebSockets; /// /// Specifies the type of disconnection that occurred in a WebSocket connection. diff --git a/generators/csharp/base/src/asIs/Async/Events/Event.Template.cs b/generators/csharp/base/src/asIs/WebSockets/Event.Template.cs similarity index 98% rename from generators/csharp/base/src/asIs/Async/Events/Event.Template.cs rename to generators/csharp/base/src/asIs/WebSockets/Event.Template.cs index 4aac654332ba..df945b86a94d 100644 --- a/generators/csharp/base/src/asIs/Async/Events/Event.Template.cs +++ b/generators/csharp/base/src/asIs/WebSockets/Event.Template.cs @@ -1,5 +1,5 @@ // ReSharper disable UnusedMember.Global -namespace <%= namespace%>.Async.Events; +namespace <%= namespace%>.WebSockets; /// /// Wraps an event that can be subscribed to and can be invoked. diff --git a/generators/csharp/base/src/asIs/Async/Query.Template.cs b/generators/csharp/base/src/asIs/WebSockets/Query.Template.cs similarity index 99% rename from generators/csharp/base/src/asIs/Async/Query.Template.cs rename to generators/csharp/base/src/asIs/WebSockets/Query.Template.cs index e2ab5b6bcd04..ad99a914a4c8 100644 --- a/generators/csharp/base/src/asIs/Async/Query.Template.cs +++ b/generators/csharp/base/src/asIs/WebSockets/Query.Template.cs @@ -1,6 +1,6 @@ using System.Collections; -namespace <%= namespace%>.Async; +namespace <%= namespace%>.WebSockets; /// /// Represents a collection of query parameters that can be used to build URL query strings. diff --git a/generators/csharp/base/src/asIs/Async/Models/ReconnectionInfo.Template.cs b/generators/csharp/base/src/asIs/WebSockets/ReconnectionInfo.Template.cs similarity index 93% rename from generators/csharp/base/src/asIs/Async/Models/ReconnectionInfo.Template.cs rename to generators/csharp/base/src/asIs/WebSockets/ReconnectionInfo.Template.cs index e98936cee0c8..e9ac2643e63a 100644 --- a/generators/csharp/base/src/asIs/Async/Models/ReconnectionInfo.Template.cs +++ b/generators/csharp/base/src/asIs/WebSockets/ReconnectionInfo.Template.cs @@ -1,6 +1,6 @@ -// ReSharper disable All +// ReSharper disable All #pragma warning disable -namespace <%= namespace%>.Async.Models; +namespace <%= namespace%>.WebSockets; /// /// Contains information about a WebSocket reconnection event. diff --git a/generators/csharp/base/src/asIs/Async/Models/ReconnectionType.Template.cs b/generators/csharp/base/src/asIs/WebSockets/ReconnectionType.Template.cs similarity index 93% rename from generators/csharp/base/src/asIs/Async/Models/ReconnectionType.Template.cs rename to generators/csharp/base/src/asIs/WebSockets/ReconnectionType.Template.cs index c27fb2537ac7..5fae4ca1f18b 100644 --- a/generators/csharp/base/src/asIs/Async/Models/ReconnectionType.Template.cs +++ b/generators/csharp/base/src/asIs/WebSockets/ReconnectionType.Template.cs @@ -1,6 +1,6 @@ -// ReSharper disable All +// ReSharper disable All // #pragma warning disable -namespace <%= namespace%>.Async.Models; +namespace <%= namespace%>.WebSockets; /// /// Specifies the type of reconnection that occurred in a WebSocket connection. diff --git a/generators/csharp/base/src/asIs/Async/RequestMessage.Template.cs b/generators/csharp/base/src/asIs/WebSockets/RequestMessage.Template.cs similarity index 96% rename from generators/csharp/base/src/asIs/Async/RequestMessage.Template.cs rename to generators/csharp/base/src/asIs/WebSockets/RequestMessage.Template.cs index ee6174e5fb90..325dd04c6f95 100644 --- a/generators/csharp/base/src/asIs/Async/RequestMessage.Template.cs +++ b/generators/csharp/base/src/asIs/WebSockets/RequestMessage.Template.cs @@ -1,6 +1,6 @@ -// ReSharper disable All +// ReSharper disable All // #pragma warning disable -namespace <%= namespace%>.Async; +namespace <%= namespace%>.WebSockets; /// /// Abstract base class for WebSocket request messages. diff --git a/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/Async/AsyncApi.cs b/generators/csharp/base/src/asIs/WebSockets/WebSocketClient.Template.cs similarity index 53% rename from seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/Async/AsyncApi.cs rename to generators/csharp/base/src/asIs/WebSockets/WebSocketClient.Template.cs index 37bd2c0933b7..dfbcdba3b8dc 100644 --- a/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/Async/AsyncApi.cs +++ b/generators/csharp/base/src/asIs/WebSockets/WebSocketClient.Template.cs @@ -1,108 +1,55 @@ using System.ComponentModel; -using System.Diagnostics.CodeAnalysis; using System.Net.WebSockets; using System.Runtime.CompilerServices; -using SeedWebsocket.Core.Async.Events; -using SeedWebsocket.Core.Async.Models; -namespace SeedWebsocket.Core.Async; +namespace <%= namespace%>.WebSockets; /// -/// Abstract base class for asynchronous API implementations that use WebSocket connections. -/// Provides common functionality for connection management, message sending, and event handling. +/// A WebSocket client that handles connection management, message sending, and event handling. /// -/// The type of API options that must inherit from AsyncApiOptions. -public abstract class AsyncApi : IAsyncDisposable, IDisposable, INotifyPropertyChanged - where T : AsyncApiOptions +internal sealed class WebSocketClient : IAsyncDisposable, IDisposable, INotifyPropertyChanged { - private T _apiOptions; - private WebSocketConnection? _webSocket; private ConnectionStatus _status = ConnectionStatus.Disconnected; + private WebSocketConnection? _webSocket; + private readonly Uri _uri; + private readonly Func _onTextMessage; /// - /// Initializes a new instance of the AsyncApi class with the specified options. - /// - /// The API configuration options. - protected internal AsyncApi(T options) - { - _apiOptions = options; - } - - /// - /// Creates the WebSocket URI for the connection. - /// - /// The URI to connect to. - protected abstract Uri CreateUri(); - - /// - /// Disposes any custom events specific to the derived class. - /// - protected abstract void DisposeEvents(); - - /// - /// Configures the WebSocket connection options before establishing the connection. - /// - /// The WebSocket client options to configure. - protected abstract void SetConnectionOptions(ClientWebSocketOptions options); - - /// - /// Handles incoming text messages from the WebSocket connection. - /// - /// The stream containing the received text message. - /// A task representing the asynchronous operation. - protected abstract Task OnTextMessage(Stream stream); - - /// - /// Handles incoming binary messages from the WebSocket connection. - /// - /// Override this method to handle binary message content. - /// (Default behavior is to do nothing) + /// Initializes a new instance of the WebSocketClient class. /// - /// The stream containing the received binary message. - /// A task representing the asynchronous operation. - protected virtual Task OnBinaryMessage(Stream stream) + /// The WebSocket URI to connect to. + /// Handler for incoming text messages. + public WebSocketClient(Uri uri, Func onTextMessage) { - stream.Dispose(); - return Task.CompletedTask; + _uri = uri; + _onTextMessage = onTextMessage; } /// - /// Gets or sets the API configuration options. + /// Gets the current connection status of the WebSocket. /// - public T ApiOptions + public ConnectionStatus Status { - get => _apiOptions; - protected set => - NotifyIfPropertyChanged( - EqualityComparer.Default.Equals(_apiOptions, value), - _apiOptions = value - ); + get => _status; + private set + { + if (_status != value) + { + _status = value; + OnPropertyChanged(); + } + } } /// - /// Gets or sets the base URL for the API connection. + /// Ensures the WebSocket is connected before sending. /// - public string BaseUrl + private void EnsureConnected() { - get => ApiOptions.BaseUrl; - protected set => - NotifyIfPropertyChanged( - EqualityComparer.Default.Equals(ApiOptions.BaseUrl), - ApiOptions.BaseUrl = value - ); - } - - /// - /// Gets the current connection status of the WebSocket. - /// - public ConnectionStatus Status - { - get => _status; - protected set => - NotifyIfPropertyChanged( - EqualityComparer.Default.Equals(_status, value), - _status = value - ); + this.Assert( + Status == ConnectionStatus.Connected, + $"Cannot send message when status is {Status}" + ); } /// @@ -111,12 +58,9 @@ public ConnectionStatus Status /// The text message to send. /// A task representing the asynchronous send operation. /// Thrown when the connection is not in Connected status. - protected internal Task SendInstant(string message) + public Task SendInstant(string message) { - this.Assert( - Status == ConnectionStatus.Connected, - $"Cannot send message when status is {Status}" - ); + EnsureConnected(); return _webSocket!.SendInstant(message); } @@ -126,12 +70,9 @@ protected internal Task SendInstant(string message) /// The binary message to send as a Memory<byte>. /// A task representing the asynchronous send operation. /// Thrown when the connection is not in Connected status. - protected internal Task SendInstant(Memory message) + public Task SendInstant(Memory message) { - this.Assert( - Status == ConnectionStatus.Connected, - $"Cannot send message when status is {Status}" - ); + EnsureConnected(); return _webSocket!.SendInstant(message); } @@ -141,12 +82,9 @@ protected internal Task SendInstant(Memory message) /// The binary message to send as an ArraySegment<byte>. /// A task representing the asynchronous send operation. /// Thrown when the connection is not in Connected status. - protected internal Task SendInstant(ArraySegment message) + public Task SendInstant(ArraySegment message) { - this.Assert( - Status == ConnectionStatus.Connected, - $"Cannot send message when status is {Status}" - ); + EnsureConnected(); return _webSocket!.SendInstant(message); } @@ -156,17 +94,14 @@ protected internal Task SendInstant(ArraySegment message) /// The binary message to send as a byte array. /// A task representing the asynchronous send operation. /// Thrown when the connection is not in Connected status. - protected internal Task SendInstant(byte[] message) + public Task SendInstant(byte[] message) { - this.Assert( - Status == ConnectionStatus.Connected, - $"Cannot send message when status is {Status}" - ); + EnsureConnected(); return _webSocket!.SendInstant(message); } /// - /// Asynchronously disposes the AsyncApi instance, closing any active connections and cleaning up resources. + /// Asynchronously disposes the WebSocketClient instance, closing any active connections and cleaning up resources. /// /// A ValueTask representing the asynchronous dispose operation. public async ValueTask DisposeAsync() @@ -187,7 +122,7 @@ public async ValueTask DisposeAsync() } /// - /// Synchronously disposes the AsyncApi instance, closing any active connections and cleaning up resources. + /// Synchronously disposes the WebSocketClient instance, closing any active connections and cleaning up resources. /// public void Dispose() { @@ -206,21 +141,20 @@ public void Dispose() } /// - /// Disposes all internal events and calls the derived class's DisposeEvents method. + /// Disposes all internal events. /// private void DisposeEventsInternal() { ExceptionOccurred.Dispose(); Closed.Dispose(); Connected.Dispose(); - DisposeEvents(); } /// /// Asynchronously closes the WebSocket connection with normal closure status. /// /// A task representing the asynchronous close operation. - public virtual async Task CloseAsync() + public async Task CloseAsync() { if (_webSocket != null) { @@ -235,7 +169,7 @@ public virtual async Task CloseAsync() /// /// A task representing the asynchronous connect operation. /// Thrown when the connection status is not Disconnected or when connection fails. - public virtual async Task ConnectAsync() + public async Task ConnectAsync() { this.Assert( Status == ConnectionStatus.Disconnected, @@ -244,22 +178,17 @@ public virtual async Task ConnectAsync() _webSocket?.Dispose(); - // the websocket connection is connecting to the target url Status = ConnectionStatus.Connecting; - _webSocket = new WebSocketConnection( - CreateUri(), - () => - { - var socket = new ClientWebSocket(); - SetConnectionOptions(socket.Options); - return socket; - } - ) + _webSocket = new WebSocketConnection(_uri, () => new ClientWebSocket()) { ExceptionOccurred = ExceptionOccurred.RaiseEvent, - TextMessageReceived = OnTextMessage, - BinaryMessageReceived = OnBinaryMessage, + TextMessageReceived = _onTextMessage, + BinaryMessageReceived = stream => + { + stream.Dispose(); + return Task.CompletedTask; + }, DisconnectionHappened = async d => { await Closed @@ -281,8 +210,6 @@ await Closed Status = ConnectionStatus.Disconnected; throw; } - - // connection has been established } /// @@ -302,25 +229,16 @@ await Closed /// /// Event that is raised when a property value changes. + /// Currently only raised for the Status property. /// public event PropertyChangedEventHandler? PropertyChanged; /// - /// Notifies subscribers of the PropertyChanged event if the property value has actually changed. + /// Raises the PropertyChanged event. /// - /// The type of the property value. - /// True if the old and new values are equal, false otherwise. - /// The new property value. /// The name of the property that changed. Automatically populated by the compiler. - protected void NotifyIfPropertyChanged( - bool isEqual, - TValue value, - [CallerMemberName] string? propertyName = null - ) + private void OnPropertyChanged([CallerMemberName] string? propertyName = null) { - if (isEqual == false) - { - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); - } + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } } diff --git a/generators/csharp/base/src/asIs/Async/WebSocketConnection.Sending.Template.cs b/generators/csharp/base/src/asIs/WebSockets/WebSocketConnection.Sending.Template.cs similarity index 98% rename from generators/csharp/base/src/asIs/Async/WebSocketConnection.Sending.Template.cs rename to generators/csharp/base/src/asIs/WebSockets/WebSocketConnection.Sending.Template.cs index 31629fb83014..9de98da2ec78 100644 --- a/generators/csharp/base/src/asIs/Async/WebSocketConnection.Sending.Template.cs +++ b/generators/csharp/base/src/asIs/WebSockets/WebSocketConnection.Sending.Template.cs @@ -1,9 +1,9 @@ -// ReSharper disable All +// ReSharper disable All #pragma warning disable using System.Net.WebSockets; using System.Text; -namespace <%= namespace%>.Async; +namespace <%= namespace%>.WebSockets; internal partial class WebSocketConnection { diff --git a/generators/csharp/base/src/asIs/Async/WebSocketConnection.Template.cs b/generators/csharp/base/src/asIs/WebSockets/WebSocketConnection.Template.cs similarity index 98% rename from generators/csharp/base/src/asIs/Async/WebSocketConnection.Template.cs rename to generators/csharp/base/src/asIs/WebSockets/WebSocketConnection.Template.cs index 575faa8fda9f..5d2ee7d72f96 100644 --- a/generators/csharp/base/src/asIs/Async/WebSocketConnection.Template.cs +++ b/generators/csharp/base/src/asIs/WebSockets/WebSocketConnection.Template.cs @@ -2,14 +2,11 @@ #pragma warning disable using System.Net.WebSockets; using System.Text; -using <%= namespace%>.Async.Exceptions; -using <%= namespace%>.Async.Models; -using <%= namespace%>.Async.Threading; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.IO; -namespace <%= namespace%>.Async; +namespace <%= namespace%>.WebSockets; /// /// A simple websocket client with built-in reconnection and error handling diff --git a/generators/csharp/base/src/asIs/Async/Exceptions/WebsocketException.Template.cs b/generators/csharp/base/src/asIs/WebSockets/WebsocketException.Template.cs similarity index 94% rename from generators/csharp/base/src/asIs/Async/Exceptions/WebsocketException.Template.cs rename to generators/csharp/base/src/asIs/WebSockets/WebsocketException.Template.cs index 8ddc6895d60f..8d79bb0e1020 100644 --- a/generators/csharp/base/src/asIs/Async/Exceptions/WebsocketException.Template.cs +++ b/generators/csharp/base/src/asIs/WebSockets/WebsocketException.Template.cs @@ -1,6 +1,6 @@ -// ReSharper disable All +// ReSharper disable All #pragma warning disable -namespace <%= namespace%>.Async.Exceptions; +namespace <%= namespace%>.WebSockets; /// /// Custom exception related to WebSocket connection operations. diff --git a/generators/csharp/base/src/asIs/test/Utils/NUnitExtensions.Template.cs b/generators/csharp/base/src/asIs/test/Utils/NUnitExtensions.Template.cs index 426df1245388..78e90e0a90fc 100644 --- a/generators/csharp/base/src/asIs/test/Utils/NUnitExtensions.Template.cs +++ b/generators/csharp/base/src/asIs/test/Utils/NUnitExtensions.Template.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/generators/csharp/base/src/asIs/test/Utils/OptionalComparer.Template.cs b/generators/csharp/base/src/asIs/test/Utils/OptionalComparer.Template.cs new file mode 100644 index 000000000000..38845ef0569a --- /dev/null +++ b/generators/csharp/base/src/asIs/test/Utils/OptionalComparer.Template.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using <%= namespaces.root %>.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/generators/csharp/base/src/context/CsharpTypeMapper.ts b/generators/csharp/base/src/context/CsharpTypeMapper.ts index cdb385914995..9af72b5accea 100644 --- a/generators/csharp/base/src/context/CsharpTypeMapper.ts +++ b/generators/csharp/base/src/context/CsharpTypeMapper.ts @@ -83,7 +83,7 @@ export class CsharpTypeMapper extends WithGeneration { return this.Collection.list(this.convert({ reference: container.list, unboxOptionals: true })); case "map": { const key = this.convert({ reference: container.keyType }); - const value = this.convert({ reference: container.valueType }); + const value = this.convert({ reference: container.valueType, unboxOptionals: true }); if (is.Primitive.object(value)) { // object map values should be nullable. return this.Collection.map(key, value.asOptional()); @@ -92,14 +92,43 @@ export class CsharpTypeMapper extends WithGeneration { } case "set": return this.Collection.set(this.convert({ reference: container.set, unboxOptionals: true })); - case "optional": - return unboxOptionals - ? this.convert({ reference: container.optional, unboxOptionals }) - : this.convert({ reference: container.optional }).asOptional(); + case "optional": { + if (unboxOptionals) { + return this.convert({ reference: container.optional, unboxOptionals }); + } + + // Use experimental explicit nullable/optional handling if enabled + if (this.generation.settings.enableExplicitNullableOptional) { + // Check if the inner type is nullable + const innerType = container.optional; + const isInnerNullable = innerType.type === "container" && innerType.container.type === "nullable"; + + // If optional wraps nullable (optional>), use Optional wrapper + // Otherwise, use T? to rely on JSON serialization's default omit-if-null behavior + if (isInnerNullable) { + return this.asOptionalWrapper(this.convert({ reference: innerType })); + } else { + return this.convert({ reference: innerType }).asOptional(); + } + } else { + // Legacy behavior: always use T? for optional + return this.convert({ reference: container.optional }).asOptional(); + } + } case "nullable": - return unboxOptionals - ? this.convert({ reference: container.nullable, unboxOptionals }) - : this.convert({ reference: container.nullable }).asOptional(); + // Use experimental explicit nullable/optional handling if enabled + if (this.generation.settings.enableExplicitNullableOptional) { + // Use ? syntax for nullable reference types + // When unwrapping optionals (e.g., inside collections), preserve nullable + return unboxOptionals + ? this.convert({ reference: container.nullable, unboxOptionals: false }).asOptional() + : this.convert({ reference: container.nullable }).asOptional(); + } else { + // Legacy behavior + return unboxOptionals + ? this.convert({ reference: container.nullable, unboxOptionals }) + : this.convert({ reference: container.nullable }).asOptional(); + } case "literal": return this.convertLiteral({ literal: container.literal }); default: @@ -107,6 +136,13 @@ export class CsharpTypeMapper extends WithGeneration { } } + /** + * Wraps a type in Optional for explicit optional/undefined semantics. + */ + private asOptionalWrapper(type: ast.Type): ast.Type { + return new ast.OptionalWrapper(type, this.generation); + } + private convertPrimitive({ primitive }: { primitive: PrimitiveType }): ast.Type { return PrimitiveTypeV1._visit(primitive.v1, { integer: () => this.Primitive.integer, diff --git a/generators/csharp/base/src/context/GeneratorContext.ts b/generators/csharp/base/src/context/GeneratorContext.ts index a35d06e12c7b..ec380a2c885c 100644 --- a/generators/csharp/base/src/context/GeneratorContext.ts +++ b/generators/csharp/base/src/context/GeneratorContext.ts @@ -355,7 +355,7 @@ export abstract class GeneratorContext extends AbstractGeneratorContext { } return this.csharp.annotation({ reference: this.csharp.classReference({ - origin: this.model.staticExplicit("JsonAccess"), + origin: this.model.staticExplicit("JsonAccessAttribute"), namespace: this.namespaces.core }), argument @@ -369,6 +369,24 @@ export abstract class GeneratorContext extends AbstractGeneratorContext { }); } + public createOptionalAttribute(): ast.Annotation { + return this.csharp.annotation({ + reference: this.csharp.classReference({ + origin: this.model.staticExplicit("OptionalAttribute"), + namespace: this.namespaces.core + }) + }); + } + + public createNullableAttribute(): ast.Annotation { + return this.csharp.annotation({ + reference: this.csharp.classReference({ + origin: this.model.staticExplicit("NullableAttribute"), + namespace: this.namespaces.core + }) + }); + } + public getCurrentVersionValueAccess(): ast.CodeBlock { return this.csharp.codeblock((writer) => { writer.writeNode(this.Types.Version); @@ -469,7 +487,14 @@ export abstract class GeneratorContext extends AbstractGeneratorContext { public isNullable(typeReference: TypeReference): boolean { switch (typeReference.type) { case "container": - return typeReference.container.type === "nullable"; + if (typeReference.container.type === "nullable") { + return true; + } + // Only check through optional wrapper if experimental flag is enabled + if (this.settings.enableExplicitNullableOptional && typeReference.container.type === "optional") { + return this.isNullable(typeReference.container.optional); + } + return false; } return false; } diff --git a/generators/csharp/codegen/src/ast/index.ts b/generators/csharp/codegen/src/ast/index.ts index 1a103fb73a3d..461d5f1e7b78 100644 --- a/generators/csharp/codegen/src/ast/index.ts +++ b/generators/csharp/codegen/src/ast/index.ts @@ -19,6 +19,7 @@ export { XmlDocWriter } from "./core/XmlDocWriter"; export { Access } from "./language/Access"; export { And } from "./language/And"; export { Annotation } from "./language/Annotation"; +export { AnnotationGroup } from "./language/AnnotationGroup"; export { AnonymousFunction } from "./language/AnonymousFunction"; export { CodeBlock } from "./language/CodeBlock"; export { Or } from "./language/Or"; @@ -35,4 +36,4 @@ export { Interface } from "./types/Interface"; export { type Type } from "./types/IType"; export { Method, MethodType } from "./types/Method"; export { TestClass } from "./types/TestClass"; -export { convertReadOnlyPrimitiveTypes } from "./types/Type"; +export { convertReadOnlyPrimitiveTypes, OptionalWrapper } from "./types/Type"; diff --git a/generators/csharp/codegen/src/ast/language/Annotation.ts b/generators/csharp/codegen/src/ast/language/Annotation.ts index ed72fd4c7b10..aaa1d667d15e 100644 --- a/generators/csharp/codegen/src/ast/language/Annotation.ts +++ b/generators/csharp/codegen/src/ast/language/Annotation.ts @@ -13,7 +13,7 @@ export declare namespace Annotation { } export class Annotation extends AstNode { - private reference: ClassReference; + public readonly reference: ClassReference; private argument?: string | AstNode; constructor(args: Annotation.Args, generation: Generation) { diff --git a/generators/csharp/codegen/src/ast/language/AnnotationGroup.ts b/generators/csharp/codegen/src/ast/language/AnnotationGroup.ts new file mode 100644 index 000000000000..6eabd12e4d14 --- /dev/null +++ b/generators/csharp/codegen/src/ast/language/AnnotationGroup.ts @@ -0,0 +1,55 @@ +import { type Generation } from "../../context/generation-info"; +import { AstNode } from "../core/AstNode"; +import { Writer } from "../core/Writer"; +import { type ClassReference } from "../types/ClassReference"; +import { type Annotation } from "./Annotation"; + +export declare namespace AnnotationGroup { + interface Args { + /* Annotations or references to the annotations in the group */ + items: (Annotation | ClassReference)[]; + } +} + +export class AnnotationGroup extends AstNode { + private items: (Annotation | ClassReference)[]; + + constructor(args: AnnotationGroup.Args, generation: Generation) { + super(generation); + this.items = args.items; + } + + public write(writer: Writer): void { + if (this.items.length === 0) { + return; + } + + // Extract references from items and add them to writer + for (const item of this.items) { + const reference = this.getReference(item); + writer.addReference(reference); + } + + writer.write("["); + for (let i = 0; i < this.items.length; i++) { + if (i > 0) { + writer.write(", "); + } + const item = this.items[i]; + if (item != null) { + const reference = this.getReference(item); + reference.writeAsAttribute(writer); + } + } + writer.write("]"); + } + + private getReference(item: Annotation | ClassReference): ClassReference { + // If it's an Annotation, extract its reference + if ("reference" in item && item.reference) { + return item.reference as ClassReference; + } + // Otherwise it's already a ClassReference + return item as ClassReference; + } +} diff --git a/generators/csharp/codegen/src/ast/types/ClassReference.ts b/generators/csharp/codegen/src/ast/types/ClassReference.ts index 7589e1655e07..5616d7a76158 100644 --- a/generators/csharp/codegen/src/ast/types/ClassReference.ts +++ b/generators/csharp/codegen/src/ast/types/ClassReference.ts @@ -132,7 +132,15 @@ export class ClassReference extends Node implements Type { return this.enclosingType ? `${this.enclosingType.name}.${this.name}` : this.name; } + private getScopedName(isAttribute: boolean): string { + const nameToWrite = + isAttribute && this.name.endsWith("Attribute") ? this.name.slice(0, -"Attribute".length) : this.name; + return this.enclosingType ? `${this.enclosingType.name}.${nameToWrite}` : nameToWrite; + } + private writeInternal(writer: Writer, isAttribute: boolean): void { + const nameToWrite = this.getScopedName(isAttribute); + // if the name (or the enclosing type name) is ambiguous const isAmbiguous = this.registry.isAmbiguousTypeName(this.name) || @@ -152,18 +160,33 @@ export class ClassReference extends Node implements Type { writer.generation.settings.useFullyQualifiedNamespaces; // the fully qualified name of the type (with global:: qualifier if it necessary) - const fqName = `${shouldGlobal ? "global::" : ""}${this.fullyQualifiedName}`; + // For attributes, strip the "Attribute" suffix from the fully qualified name + let fqNameBase = this.fullyQualifiedName; + if (isAttribute && fqNameBase.endsWith("Attribute")) { + // Replace the last occurrence of "Attribute" with empty string + const lastDotIndex = fqNameBase.lastIndexOf("."); + if (lastDotIndex >= 0) { + const namespacePart = fqNameBase.substring(0, lastDotIndex + 1); + const namePart = fqNameBase.substring(lastDotIndex + 1); + if (namePart.endsWith("Attribute")) { + fqNameBase = namespacePart + namePart.slice(0, -"Attribute".length); + } + } else { + fqNameBase = fqNameBase.slice(0, -"Attribute".length); + } + } + const fqName = `${shouldGlobal ? "global::" : ""}${fqNameBase}`; if (!this.namespace) { - writer.write(this.name); + writer.write(nameToWrite); return; } if (this.namespaceAlias != null) { const alias = writer.addNamespaceAlias(this.namespaceAlias, this.resolveNamespace()); - writer.write(`${alias}.${this.scopedName}`); + writer.write(`${alias}.${nameToWrite}`); } else { if (writer.skipImports) { - writer.write(this.scopedName); + writer.write(nameToWrite); } else { if (this.fullyQualified) { writer.write(fqName); @@ -184,7 +207,7 @@ export class ClassReference extends Node implements Type { ) { writer.write(fqName); } else { - writer.write(`${typeQualification}${this.scopedName}`); + writer.write(`${typeQualification}${nameToWrite}`); } } else if (isAmbiguous && this.resolveNamespace() !== writer.namespace) { // If the class is ambiguous and not in this specific namespace @@ -195,7 +218,7 @@ export class ClassReference extends Node implements Type { // If the class is not ambiguous and is in this specific namespace, // we can use the short name writer.addReference(this); - writer.write(this.scopedName); + writer.write(nameToWrite); } } } @@ -334,21 +357,37 @@ export class ClassReference extends Node implements Type { return true; } + // For attributes, check if there's a real conflict with another attribute type + // In C#, [Foo] automatically looks for FooAttribute, not Foo + // So [Nullable] won't conflict with System.Nullable - it looks for NullableAttribute + if (isAttribute) { + const hasConflict = this.potentialConflictWithGeneratedType(writer, isAttribute); + if (!hasConflict) { + // No conflict with another attribute, so we can rely on the using statement + return false; + } + } + // For child namespaces (like SeedCsharpNamespaceConflict.A.Aa from SeedCsharpNamespaceConflict.A), // we generally don't need qualification unless there's a specific conflict if (this.namespace.startsWith(`${currentNamespace}.`)) { // Only require qualification if there's an actual naming conflict - return this.potentialConflictWithGeneratedType(writer); + return this.potentialConflictWithGeneratedType(writer, isAttribute); } // Check for potential conflicts with generated types regardless of namespace // This handles both internal and external types consistently - return this.potentialConflictWithGeneratedType(writer); + return this.potentialConflictWithGeneratedType(writer, isAttribute); } - private potentialConflictWithGeneratedType(writer: Writer) { + private potentialConflictWithGeneratedType(writer: Writer, isAttribute: boolean = false) { + // For attributes, we check for conflicts differently + // In C#, [Foo] looks for FooAttribute first, then falls back to Foo + // Since our attribute classes are registered with names like "Nullable" (not "NullableAttribute"), + // we just check if there's another type with the same name in a different namespace const matchingNamespaces = writer.getAllTypeClassReferences().get(this.name); if (matchingNamespaces == null) { + // No types with this name at all, so no conflict return false; } @@ -358,6 +397,12 @@ export class ClassReference extends Node implements Type { matchingNamespacesCopy.delete(this.namespace); if (matchingNamespacesCopy.size === 0) { + // No other types with this name in other namespaces + // For attributes in attribute context, there's no conflict with non-attribute types + // because C# looks for FooAttribute when you write [Foo] + if (isAttribute) { + return false; + } // Even if there's no type conflict, check for namespace conflicts // This handles cases like class "A" conflicting with namespace "A" return this.hasProjectNamespaceConflict(writer); @@ -373,6 +418,12 @@ export class ClassReference extends Node implements Type { } } + // For attributes, if we haven't found a conflict yet, there isn't one + // (because C# disambiguates [Foo] vs Foo automatically) + if (isAttribute) { + return false; + } + // Also check if the class name matches any namespace segment in the project return this.hasProjectNamespaceConflict(writer); } diff --git a/generators/csharp/codegen/src/ast/types/Field.ts b/generators/csharp/codegen/src/ast/types/Field.ts index 6422dc3b8055..d640cf9ca0ba 100644 --- a/generators/csharp/codegen/src/ast/types/Field.ts +++ b/generators/csharp/codegen/src/ast/types/Field.ts @@ -7,6 +7,7 @@ import { MemberNode } from "../core/AstNode"; import { Writer } from "../core/Writer"; import { Access } from "../language/Access"; import { Annotation } from "../language/Annotation"; +import { AnnotationGroup } from "../language/AnnotationGroup"; import { CodeBlock } from "../language/CodeBlock"; import { XmlDocBlock } from "../language/XmlDocBlock"; import { ClassReference } from "./ClassReference"; @@ -17,6 +18,10 @@ export declare namespace Field { get?: (writer: Writer) => void; set?: (writer: Writer) => void; init?: (writer: Writer) => void; + /** For C# events: the add accessor */ + add?: (writer: Writer) => void; + /** For C# events: the remove accessor */ + remove?: (writer: Writer) => void; }; interface Args extends MemberNode.Args { @@ -54,7 +59,7 @@ export declare namespace Field { /* Whether the field is readonly */ readonly?: boolean; /* Field annotations */ - annotations?: (Annotation | ClassReference)[]; + annotations?: (Annotation | AnnotationGroup | ClassReference)[]; /* The initializer for the field */ initializer?: CodeBlock | ClassInstantiation; /* The summary tag (used for describing the field) */ @@ -74,6 +79,8 @@ export declare namespace Field { /* If specified, use the accessor methods for the field implementation */ accessors?: Accessors; + /* If true, the field is a C# event (uses event keyword with add/remove accessors) */ + isEvent?: boolean; } } @@ -98,7 +105,7 @@ export class Field extends MemberNode { private readonly init: Access | boolean; private readonly set: Access | boolean; private readonly new_: boolean; - private readonly annotations: Annotation[]; + private readonly annotations: (Annotation | AnnotationGroup)[]; private readonly initializer?: CodeBlock | ClassInstantiation; private readonly doc: XmlDocBlock; private readonly jsonPropertyName?: string; @@ -110,8 +117,11 @@ export class Field extends MemberNode { get?: (writer: Writer) => void; set?: (writer: Writer) => void; init?: (writer: Writer) => void; + add?: (writer: Writer) => void; + remove?: (writer: Writer) => void; }; private readonly override?: boolean; + private readonly isEvent_: boolean; constructor( { name, @@ -134,6 +144,7 @@ export class Field extends MemberNode { interfaceReference, accessors, override, + isEvent, origin, enclosingType }: Field.Args, @@ -174,6 +185,7 @@ export class Field extends MemberNode { this.interfaceReference = interfaceReference; this.accessors = accessors; this.override = override ?? false; + this.isEvent_ = isEvent ?? false; if (this.jsonPropertyName != null) { this.annotations = [ this.csharp.annotation({ @@ -219,6 +231,10 @@ export class Field extends MemberNode { return this.type.isOptional; } + public get isEvent(): boolean { + return this.isEvent_; + } + public write(writer: Writer): void { writer.writeNode(this.doc); @@ -251,6 +267,10 @@ export class Field extends MemberNode { if (this.readonly) { writer.write("readonly "); } + // For C# events, add the event keyword + if (this.isEvent_) { + writer.write("event "); + } writer.writeNode(this.type); writer.write(" "); if (this.interfaceReference) { @@ -258,6 +278,22 @@ export class Field extends MemberNode { } writer.write(this.name); + // Handle C# events with add/remove accessors + if (this.isEvent_ && this.accessors?.add && this.accessors?.remove) { + writer.writeLine(""); + writer.writeLine("{"); + writer.indent(); + writer.write("add => "); + this.accessors.add(writer); + writer.writeLine(";"); + writer.write("remove => "); + this.accessors.remove(writer); + writer.writeLine(";"); + writer.dedent(); + writer.writeLine("}"); + return; + } + // TODO: refactor useExpressionBodiedPropertySyntax to be an argument that defaults to false // expression body will run the code every time, which is not the intended/expected behavior of initializer const useExpressionBodiedPropertySyntax = this.get && !this.init && !this.set && this.initializer != null; diff --git a/generators/csharp/codegen/src/ast/types/Type.ts b/generators/csharp/codegen/src/ast/types/Type.ts index 3fce4f092e8f..d0562d37576e 100644 --- a/generators/csharp/codegen/src/ast/types/Type.ts +++ b/generators/csharp/codegen/src/ast/types/Type.ts @@ -226,6 +226,56 @@ export class Optional extends ReferenceType { } } +/** + * Represents a wrapped Optional type in C#. + * This renders as Optional where T is the inner type. + * Used for explicit optional/undefined semantics in API requests. + */ +export class OptionalWrapper extends ReferenceType { + public override readonly isOptional = true; + + /** + * The underlying type wrapped in Optional. + */ + public readonly value: Type; + + /** + * Creates a new Optional wrapper type. + * @param value - The underlying type to wrap + * @param generation - The generation context for code generation + */ + constructor(value: Type, generation: Generation) { + super(generation); + this.value = value; + } + + public override get isCollection(): boolean { + return false; + } + + public override get multipartMethodName(): string | null { + return this.value.multipartMethodName; + } + + public override get multipartMethodNameForCollection(): string | null { + return this.value.multipartMethodNameForCollection; + } + + public override asOptional(): Type { + return this; + } + + public override asNonOptional(): Type { + return this.value; + } + + public override write(writer: Writer): void { + writer.write("Optional<"); + this.value.write(writer); + writer.write(">"); + } +} + export namespace Primitive { /** * Represents the C# `int` type (32-bit signed integer). diff --git a/generators/csharp/codegen/src/context/extern.ts b/generators/csharp/codegen/src/context/extern.ts index ba75250a20ea..e92e5f0c2f53 100644 --- a/generators/csharp/codegen/src/context/extern.ts +++ b/generators/csharp/codegen/src/context/extern.ts @@ -108,7 +108,7 @@ export class Extern { */ Serializable: () => this.csharp.classReference({ - name: "Serializable", + name: "SerializableAttribute", namespace: "System" }), @@ -174,7 +174,7 @@ export class Extern { */ EnumMember: () => this.csharp.classReference({ - name: "EnumMember", + name: "EnumMemberAttribute", namespace: "System.Runtime.Serialization" }) }) @@ -593,7 +593,7 @@ export class Extern { */ JsonExtensionData: () => this.csharp.classReference({ - name: "JsonExtensionData", + name: "JsonExtensionDataAttribute", namespace: "System.Text.Json.Serialization" }), @@ -616,7 +616,7 @@ export class Extern { */ JsonIgnore: () => this.csharp.classReference({ - name: "JsonIgnore", + name: "JsonIgnoreAttribute", namespace: "System.Text.Json.Serialization" }), @@ -625,7 +625,7 @@ export class Extern { */ JsonPropertyName: () => this.csharp.classReference({ - name: "JsonPropertyName", + name: "JsonPropertyNameAttribute", namespace: "System.Text.Json.Serialization" }) }) @@ -662,8 +662,59 @@ export class Extern { namespace: "System.Threading.Tasks", generics: ofType ? [ofType] : undefined }); + }, + /** + * Creates a reference to ValueTask or ValueTask. + * + * @param ofType - The result type (optional) + * @returns A ClassReference for ValueTask + */ + ValueTask: (ofType?: Type) => { + return this.csharp.classReference({ + name: "ValueTask", + namespace: "System.Threading.Tasks", + generics: ofType ? [ofType] : undefined + }); } }) + }), + /** + * ComponentModel namespace references. + */ + ComponentModel: () => + lazy({ + /** + * Reference to System.ComponentModel.INotifyPropertyChanged interface. + */ + INotifyPropertyChanged: () => + this.csharp.classReference({ + name: "INotifyPropertyChanged", + namespace: "System.ComponentModel" + }), + /** + * Reference to System.ComponentModel.PropertyChangedEventHandler delegate. + */ + PropertyChangedEventHandler: () => + this.csharp.classReference({ + name: "PropertyChangedEventHandler", + namespace: "System.ComponentModel" + }) + }), + /** + * Reference to System.IAsyncDisposable interface. + */ + IAsyncDisposable: () => + this.csharp.classReference({ + name: "IAsyncDisposable", + namespace: "System" + }), + /** + * Reference to System.IDisposable interface. + */ + IDisposable: () => + this.csharp.classReference({ + name: "IDisposable", + namespace: "System" }) }); diff --git a/generators/csharp/codegen/src/context/generation-info.ts b/generators/csharp/codegen/src/context/generation-info.ts index ed9cf671b8e3..eb740ab3eae8 100644 --- a/generators/csharp/codegen/src/context/generation-info.ts +++ b/generators/csharp/codegen/src/context/generation-info.ts @@ -146,6 +146,8 @@ export class Generation { enableWebsockets: () => this.customConfig["experimental-enable-websockets"] ?? false, /** When true, generates readonly constants instead of static properties. Default: false. */ enableReadonlyConstants: () => this.customConfig["experimental-readonly-constants"] ?? false, + /** When true, uses explicit nullable/optional attributes and Optional wrapper for better null handling. Default: false. */ + enableExplicitNullableOptional: () => this.customConfig["experimental-explicit-nullable-optional"] ?? false, /** Temporary mapping of websocket environment configurations. Default: {}. */ temporaryWebsocketEnvironments: () => this.customConfig["temporary-websocket-environments"] ?? {}, /** Custom name for the base API exception class. Default: "" (auto-generated). */ @@ -293,7 +295,7 @@ export class Generation { * - `testUtils`: Helper methods for tests * - `mockServerTest`: Mock server testing infrastructure * - `publicCore`: Public core utilities exposed to SDK users - * - `asyncCore`: Asynchronous API utilities (websockets, streaming) + * - `webSocketsCore`: WebSocket API utilities * - `publicCoreTest`: Tests for public core functionality * - `asIsTestUtils`: Test utilities that preserve original casing * - `publicCoreClasses`: Location for core classes based on rootNamespaceForCoreClasses setting @@ -314,8 +316,8 @@ export class Generation { mockServerTest: (): string => `${this.namespaces.test}.Unit.MockServer`, /** Public Core namespace, same as root for publicly exposed core utilities. */ publicCore: (): string => this.namespaces.root, - /** Async Core namespace for asynchronous APIs like websockets and streaming ({root}.Core.Async). */ - asyncCore: (): string => `${this.namespaces.core}.Async`, + /** WebSockets Core namespace for WebSocket APIs ({root}.Core.WebSockets). */ + webSocketsCore: (): string => `${this.namespaces.core}.WebSockets`, /** Public Core test namespace for testing public core functionality ({root}.Test.PublicCore). */ publicCoreTest: (): string => `${this.namespaces.root}.Test.PublicCore`, /** Test utilities namespace that preserves original casing ({root}.Test.Utils). */ @@ -422,7 +424,7 @@ export class Generation { * ### Client Infrastructure: * - `RootClient`, `RootClientForSnippets`: Main SDK client classes * - `TestClient`: Testing infrastructure - * - `AsyncApi`: Asynchronous API support (websockets, streaming) + * - `WebSocketClient`: WebSocket client for managing connections * * ### Error Handling: * - `BaseException`, `BaseApiException`: Exception hierarchy @@ -468,7 +470,7 @@ export class Generation { * ### Generic Types (evaluated per call): * ```typescript * const pager = generation.Types.Pager(itemType); // Returns new ClassReference each time - * const asyncApi = generation.Types.AsyncApi(messageType); + * const webSocketClient = generation.Types.WebSocketClient(); * ``` * * All type references include proper namespace information and are registered with @@ -670,17 +672,17 @@ export class Generation { origin: this.model.staticExplicit("IStringEnum"), namespace: this.namespaces.core }), - /** Configuration options for asynchronous APIs (websockets, streaming) */ - AsyncApiOptions: () => + /** WebSocket client for managing WebSocket connections */ + WebSocketClient: () => this.csharp.classReference({ - origin: this.model.staticExplicit("AsyncApiOptions"), - namespace: `${this.namespaces.asyncCore}.Models` + origin: this.model.staticExplicit("WebSocketClient"), + namespace: this.namespaces.webSocketsCore }), - /** Query string builder utility */ + /** Query string builder utility for WebSocket URLs */ QueryBuilder: () => this.csharp.classReference({ origin: this.model.staticExplicit("Query"), - namespace: this.namespaces.asyncCore + namespace: this.namespaces.webSocketsCore }), /** OAuth token provider for authentication */ OAuthTokenProvider: () => @@ -735,27 +737,34 @@ export class Generation { generics: genericType ? [genericType] : undefined }), /** - * Generic asynchronous API wrapper for websockets and streaming. - * @param genericType - The message type for the async API - */ - AsyncApi: (genericType: ast.Type | ast.ClassReference): ast.ClassReference => { - return this.csharp.classReference({ - origin: this.model.staticExplicit("AsyncApi"), - namespace: this.namespaces.asyncCore, - generics: [genericType] - }); - }, - /** - * Generic event wrapper for asynchronous APIs. + * Generic event wrapper for WebSocket APIs. * @param genericType - The event payload type */ - AsyncEvent: (genericType: ast.Type | ast.ClassReference): ast.ClassReference => { + WebSocketEvent: (genericType: ast.Type | ast.ClassReference): ast.ClassReference => { return this.csharp.classReference({ origin: this.model.staticExplicit("Event"), - namespace: `${this.namespaces.asyncCore}.Events`, + namespace: this.namespaces.webSocketsCore, generics: [genericType] }); }, + /** Connection status enum for WebSocket connections */ + ConnectionStatus: () => + this.csharp.classReference({ + origin: this.model.staticExplicit("ConnectionStatus"), + namespace: this.namespaces.webSocketsCore + }), + /** Connected event for WebSocket connections */ + WebSocketConnected: () => + this.csharp.classReference({ + origin: this.model.staticExplicit("Connected"), + namespace: this.namespaces.webSocketsCore + }), + /** Closed event for WebSocket connections */ + WebSocketClosed: () => + this.csharp.classReference({ + origin: this.model.staticExplicit("Closed"), + namespace: this.namespaces.webSocketsCore + }), /** * JSON serializer for string-based enum types. * @param enumClassReference - The enum type to serialize diff --git a/generators/csharp/codegen/src/csharp.ts b/generators/csharp/codegen/src/csharp.ts index 221f69323dd5..625947ac9b07 100644 --- a/generators/csharp/codegen/src/csharp.ts +++ b/generators/csharp/codegen/src/csharp.ts @@ -4,6 +4,7 @@ import { dynamic, Name, NameAndWireValue } from "@fern-fern/ir-sdk/api"; import { And, Annotation, + AnnotationGroup, AnonymousFunction, AstNode, Set as AstSet, @@ -235,6 +236,17 @@ export class CSharp { return new Annotation(args, this.generation); } + /** + * Creates a C# annotation group (multiple attributes in a single bracket). + * + * @param args - Configuration for the annotation group including multiple references + * @returns An AnnotationGroup object representing multiple C# attributes + * @example [Nullable, Optional] + */ + public annotationGroup(args: AnnotationGroup.Args): AnnotationGroup { + return new AnnotationGroup(args, this.generation); + } + /** * Creates a C# class instantiation (new ClassName() expression). * diff --git a/generators/csharp/codegen/src/custom-config/CsharpConfigSchema.ts b/generators/csharp/codegen/src/custom-config/CsharpConfigSchema.ts index 6bf325d491bd..e9d2d8e3202e 100644 --- a/generators/csharp/codegen/src/custom-config/CsharpConfigSchema.ts +++ b/generators/csharp/codegen/src/custom-config/CsharpConfigSchema.ts @@ -47,6 +47,7 @@ export const CsharpConfigSchema = z.object({ // new experimental options "experimental-enable-websockets": z.boolean().optional(), "experimental-readonly-constants": z.boolean().optional(), + "experimental-explicit-nullable-optional": z.boolean().optional(), // temporary options to unblock websocket URIs generation // diff --git a/generators/csharp/model/src/ModelGeneratorContext.ts b/generators/csharp/model/src/ModelGeneratorContext.ts index f54436b3762c..0f39ff720675 100644 --- a/generators/csharp/model/src/ModelGeneratorContext.ts +++ b/generators/csharp/model/src/ModelGeneratorContext.ts @@ -71,7 +71,10 @@ export class ModelGeneratorContext extends GeneratorContext { AsIsFiles.Json.DateTimeSerializer, AsIsFiles.Json.JsonAccessAttribute, AsIsFiles.Json.JsonConfiguration, - AsIsFiles.Json.OneOfSerializer + AsIsFiles.Json.Nullable, + AsIsFiles.Json.OneOfSerializer, + AsIsFiles.Json.Optional, + AsIsFiles.Json.OptionalAttribute ] ); diff --git a/generators/csharp/model/src/generateFields.ts b/generators/csharp/model/src/generateFields.ts index 737d98e261a8..e3dc51dd561a 100644 --- a/generators/csharp/model/src/generateFields.ts +++ b/generators/csharp/model/src/generateFields.ts @@ -1,9 +1,73 @@ import { ast, Writer } from "@fern-api/csharp-codegen"; import { FernIr } from "@fern-fern/ir-sdk"; +import { TypeReference } from "@fern-fern/ir-sdk/api"; import { ModelGeneratorContext } from "./ModelGeneratorContext"; +interface TypeInfo { + isOptional: boolean; + isNullable: boolean; +} + +/** + * Analyzes a TypeReference to determine if it's optional and/or nullable. + * - optional: Type is wrapped in Optional container + * - nullable: Type is wrapped in nullable container OR optional with nullable inner type + */ +function analyzeTypeReference(typeReference: TypeReference, context: ModelGeneratorContext): TypeInfo { + const result: TypeInfo = { + isOptional: false, + isNullable: false + }; + + let current: TypeReference = typeReference; + + // Unwrap containers and aliases to find optional/nullable wrappers + while (true) { + if (current.type === "container") { + const container = current.container; + + if (container.type === "optional") { + result.isOptional = true; + current = container.optional; + } else if (container.type === "nullable") { + result.isNullable = true; + // If we have nullable inside optional, keep unwrapping + // e.g., optional> -> Optional with both attributes + if (result.isOptional) { + current = container.nullable; + } else { + // nullable without optional -> just a nullable reference type with [Nullable] + current = container.nullable; + } + } else if (container.type === "list" || container.type === "set" || container.type === "map") { + // Collections are not optional/nullable themselves + break; + } else if (container.type === "literal") { + // Literals are not optional/nullable + break; + } else { + break; + } + } else if (current.type === "named") { + // Resolve aliases to their underlying types + const typeDeclaration = context.model.dereferenceType(current.typeId).typeDeclaration; + if (typeDeclaration.shape.type === "alias") { + current = typeDeclaration.shape.aliasOf; + } else { + // Not an alias, we're done + break; + } + } else { + // Primitive, unknown, etc. + break; + } + } + + return result; +} + export function generateFields( cls: ast.Class, { @@ -37,11 +101,31 @@ export function generateField( const maybeLiteralInitializer = context.getLiteralInitializerFromTypeReference({ typeReference: property.valueType }); + + // Analyze the type to determine if it's optional and/or nullable + const typeInfo = analyzeTypeReference(property.valueType, context); + const fieldAttributes = []; if (jsonProperty) { if ("propertyAccess" in property && property.propertyAccess) { fieldAttributes.push(context.createJsonAccessAttribute(property.propertyAccess)); } + + // Add Optional/Nullable attributes - combine them if both are present (only if feature flag is enabled) + if (context.generation.settings.enableExplicitNullableOptional) { + if (typeInfo.isOptional && typeInfo.isNullable) { + // Both optional and nullable - use annotation group [Nullable, Optional] + const items = [context.createNullableAttribute(), context.createOptionalAttribute()]; + fieldAttributes.push(context.csharp.annotationGroup({ items })); + } else if (typeInfo.isOptional) { + // Only optional + fieldAttributes.push(context.createOptionalAttribute()); + } else if (typeInfo.isNullable) { + // Only nullable + fieldAttributes.push(context.createNullableAttribute()); + } + } + fieldAttributes.push(context.createJsonPropertyNameAttribute(property.name.wireValue)); } // if we are using readonly constants, we need to generate the accessors and initializer diff --git a/generators/csharp/model/src/snippets/ExampleGenerator.ts b/generators/csharp/model/src/snippets/ExampleGenerator.ts index fbc3e3f03bd4..3cf0d1123b08 100644 --- a/generators/csharp/model/src/snippets/ExampleGenerator.ts +++ b/generators/csharp/model/src/snippets/ExampleGenerator.ts @@ -223,7 +223,7 @@ export class ExampleGenerator extends WithGeneration { valueType: p.valueType.type === "unknown" ? this.context.csharpTypeMapper.convert({ reference: p.valueType }).asOptional() - : this.context.csharpTypeMapper.convert({ reference: p.valueType }), + : this.context.csharpTypeMapper.convert({ reference: p.valueType, unboxOptionals: true }), values: { type: "entries", entries diff --git a/generators/csharp/sdk/src/SdkGeneratorContext.ts b/generators/csharp/sdk/src/SdkGeneratorContext.ts index 10fa122b16f9..0e8ced71f986 100644 --- a/generators/csharp/sdk/src/SdkGeneratorContext.ts +++ b/generators/csharp/sdk/src/SdkGeneratorContext.ts @@ -154,7 +154,10 @@ export class SdkGeneratorContext extends GeneratorContext { AsIsFiles.Json.DateTimeSerializer, AsIsFiles.Json.JsonAccessAttribute, AsIsFiles.Json.JsonConfiguration, - AsIsFiles.Json.OneOfSerializer + AsIsFiles.Json.Nullable, + AsIsFiles.Json.OneOfSerializer, + AsIsFiles.Json.Optional, + AsIsFiles.Json.OptionalAttribute ] ); // HTTP stuff @@ -257,7 +260,7 @@ export class SdkGeneratorContext extends GeneratorContext { } } } - recurse("Async", AsIsFiles.WebSocketAsync); + recurse("WebSockets", AsIsFiles.WebSockets); return files; } diff --git a/generators/csharp/sdk/src/endpoint/request/WrappedEndpointRequest.ts b/generators/csharp/sdk/src/endpoint/request/WrappedEndpointRequest.ts index bb156ce6acef..7dde46117457 100644 --- a/generators/csharp/sdk/src/endpoint/request/WrappedEndpointRequest.ts +++ b/generators/csharp/sdk/src/endpoint/request/WrappedEndpointRequest.ts @@ -50,41 +50,106 @@ export class WrappedEndpointRequest extends EndpointRequest { if (this.endpoint.queryParameters.length === 0) { return undefined; } - const requiredQueryParameters: QueryParameter[] = []; - const nullableQueryParameters: QueryParameter[] = []; - for (const queryParameter of this.endpoint.queryParameters) { - if ( - (!queryParameter.allowMultiple && this.context.isOptional(queryParameter.valueType)) || - this.context.isNullable(queryParameter.valueType) - ) { - nullableQueryParameters.push(queryParameter); - } else { - requiredQueryParameters.push(queryParameter); - } - } - return { - code: this.csharp.codeblock((writer) => { - writer.write(`var ${this.names.variables.query} = `); - writer.writeNodeStatement( - this.csharp.dictionary({ - keyType: this.Primitive.string, - valueType: this.Primitive.object, - values: undefined - }) - ); - for (const query of requiredQueryParameters) { - this.writeQueryParameter(writer, query); + // Use experimental explicit nullable/optional handling if enabled + if (this.context.generation.settings.enableExplicitNullableOptional) { + const requiredQueryParameters: QueryParameter[] = []; + const optionalAndNullableQueryParameters: QueryParameter[] = []; + const optionalOnlyQueryParameters: QueryParameter[] = []; + const nullableOnlyQueryParameters: QueryParameter[] = []; + + for (const queryParameter of this.endpoint.queryParameters) { + const isOptional = !queryParameter.allowMultiple && this.context.isOptional(queryParameter.valueType); + const isNullable = this.context.isNullable(queryParameter.valueType); + + if (isOptional && isNullable) { + // optional + nullable => Optional - check IsDefined, can be value or null + optionalAndNullableQueryParameters.push(queryParameter); + } else if (isOptional) { + // optional only => T? - check != null + optionalOnlyQueryParameters.push(queryParameter); + } else if (isNullable) { + // nullable only => T? - always include (can be value or null) + nullableOnlyQueryParameters.push(queryParameter); + } else { + // required => T - always include + requiredQueryParameters.push(queryParameter); } - for (const query of nullableQueryParameters) { - const queryParameterReference = `${this.getParameterName()}.${query.name.name.pascalCase.safeName}`; - writer.controlFlow("if", this.csharp.codeblock(`${queryParameterReference} != null`)); - this.writeQueryParameter(writer, query); - writer.endControlFlow(); + } + + return { + code: this.csharp.codeblock((writer) => { + writer.write(`var ${this.names.variables.query} = `); + writer.writeNodeStatement( + this.csharp.dictionary({ + keyType: this.Primitive.string, + valueType: this.Primitive.object, + values: undefined + }) + ); + // Required params - always include + for (const query of requiredQueryParameters) { + this.writeQueryParameter(writer, query); + } + // Nullable-only params - always include (can be value or null) + for (const query of nullableOnlyQueryParameters) { + this.writeQueryParameter(writer, query); + } + // Optional-only params - include only if not null + for (const query of optionalOnlyQueryParameters) { + const queryParameterReference = `${this.getParameterName()}.${query.name.name.pascalCase.safeName}`; + writer.controlFlow("if", this.csharp.codeblock(`${queryParameterReference} != null`)); + this.writeQueryParameter(writer, query); + writer.endControlFlow(); + } + // Optional + Nullable params - include if IsDefined (can be value or null) + for (const query of optionalAndNullableQueryParameters) { + const queryParameterReference = `${this.getParameterName()}.${query.name.name.pascalCase.safeName}`; + writer.controlFlow("if", this.csharp.codeblock(`${queryParameterReference}.IsDefined`)); + this.writeQueryParameter(writer, query); + writer.endControlFlow(); + } + }), + queryParameterBagReference: this.names.variables.query + }; + } else { + // Legacy behavior: simple nullable check + const requiredQueryParameters: QueryParameter[] = []; + const nullableQueryParameters: QueryParameter[] = []; + for (const queryParameter of this.endpoint.queryParameters) { + if ( + (!queryParameter.allowMultiple && this.context.isOptional(queryParameter.valueType)) || + this.context.isNullable(queryParameter.valueType) + ) { + nullableQueryParameters.push(queryParameter); + } else { + requiredQueryParameters.push(queryParameter); } - }), - queryParameterBagReference: this.names.variables.query - }; + } + + return { + code: this.csharp.codeblock((writer) => { + writer.write(`var ${this.names.variables.query} = `); + writer.writeNodeStatement( + this.csharp.dictionary({ + keyType: this.Primitive.string, + valueType: this.Primitive.object, + values: undefined + }) + ); + for (const query of requiredQueryParameters) { + this.writeQueryParameter(writer, query); + } + for (const query of nullableQueryParameters) { + const queryParameterReference = `${this.getParameterName()}.${query.name.name.pascalCase.safeName}`; + writer.controlFlow("if", this.csharp.codeblock(`${queryParameterReference} != null`)); + this.writeQueryParameter(writer, query); + writer.endControlFlow(); + } + }), + queryParameterBagReference: this.names.variables.query + }; + } } private writeQueryParameter(writer: Writer, query: QueryParameter): void { @@ -117,59 +182,159 @@ export class WrappedEndpointRequest extends EndpointRequest { if (headers.length === 0) { return undefined; } - const requiredHeaders: HttpHeader[] = []; - const optionalHeaders: HttpHeader[] = []; - for (const header of headers) { - if (this.context.isOptional(header.valueType)) { - optionalHeaders.push(header); - } else { - requiredHeaders.push(header); + + // Use experimental explicit nullable/optional handling if enabled + if (this.context.generation.settings.enableExplicitNullableOptional) { + const requiredHeaders: HttpHeader[] = []; + const optionalAndNullableHeaders: HttpHeader[] = []; + const optionalOnlyHeaders: HttpHeader[] = []; + const nullableOnlyHeaders: HttpHeader[] = []; + + for (const header of headers) { + const isOptional = this.context.isOptional(header.valueType); + const isNullable = this.context.isNullable(header.valueType); + + if (isOptional && isNullable) { + // optional + nullable => Optional - check IsDefined, can be value or null + optionalAndNullableHeaders.push(header); + } else if (isOptional) { + // optional only => T? - check != null + optionalOnlyHeaders.push(header); + } else if (isNullable) { + // nullable only => T? - always include (can be value or null) + nullableOnlyHeaders.push(header); + } else { + // required => T - always include + requiredHeaders.push(header); + } } - } - return { - code: this.csharp.codeblock((writer) => { - writer.write(`var ${this.names.variables.headers} = `); - writer.writeNodeStatement( - this.csharp.instantiateClass({ - classReference: this.Types.Headers, - arguments_: [ - this.csharp.dictionary({ - keyType: this.Primitive.string, - valueType: this.Primitive.string, - values: { - type: "entries", - entries: requiredHeaders.map((header) => { - return { - key: this.csharp.codeblock( - this.csharp.string_({ string: header.name.wireValue }) - ), - value: this.stringify({ - reference: header.valueType, - name: header.name.name + return { + code: this.csharp.codeblock((writer) => { + writer.write(`var ${this.names.variables.headers} = `); + writer.writeNodeStatement( + this.csharp.instantiateClass({ + classReference: this.Types.Headers, + arguments_: [ + this.csharp.dictionary({ + keyType: this.Primitive.string, + valueType: this.Primitive.string, + values: { + type: "entries", + entries: [ + ...requiredHeaders.map((header) => { + return { + key: this.csharp.codeblock( + this.csharp.string_({ string: header.name.wireValue }) + ), + value: this.stringify({ + reference: header.valueType, + name: header.name.name + }) + }; + }), + ...nullableOnlyHeaders.map((header) => { + return { + key: this.csharp.codeblock( + this.csharp.string_({ string: header.name.wireValue }) + ), + value: this.stringify({ + reference: header.valueType, + name: header.name.name + }) + }; }) - }; - }) - } + ] + } + }) + ] + }) + ); + // Optional-only headers - include only if not null + for (const header of optionalOnlyHeaders) { + const headerReference = `${this.getParameterName()}.${header.name.name.pascalCase.safeName}`; + writer.controlFlow("if", this.csharp.codeblock(`${headerReference} != null`)); + writer.write(`${this.names.variables.headers}["${header.name.wireValue}"] = `); + writer.writeNodeStatement( + this.stringify({ + reference: header.valueType, + name: header.name.name }) - ] - }) - ); - for (const header of optionalHeaders) { - const headerReference = `${this.getParameterName()}.${header.name.name.pascalCase.safeName}`; - writer.controlFlow("if", this.csharp.codeblock(`${headerReference} != null`)); - writer.write(`${this.names.variables.headers}["${header.name.wireValue}"] = `); + ); + writer.endControlFlow(); + } + // Optional + Nullable headers - include if IsDefined (can be value or null) + for (const header of optionalAndNullableHeaders) { + const headerReference = `${this.getParameterName()}.${header.name.name.pascalCase.safeName}`; + writer.controlFlow("if", this.csharp.codeblock(`${headerReference}.IsDefined`)); + writer.write(`${this.names.variables.headers}["${header.name.wireValue}"] = `); + writer.writeNodeStatement( + this.stringify({ + reference: header.valueType, + name: header.name.name + }) + ); + writer.endControlFlow(); + } + }), + headerParameterBagReference: this.names.variables.headers + }; + } else { + // Legacy behavior: simple optional check + const requiredHeaders: HttpHeader[] = []; + const optionalHeaders: HttpHeader[] = []; + for (const header of headers) { + if (this.context.isOptional(header.valueType)) { + optionalHeaders.push(header); + } else { + requiredHeaders.push(header); + } + } + + return { + code: this.csharp.codeblock((writer) => { + writer.write(`var ${this.names.variables.headers} = `); writer.writeNodeStatement( - this.stringify({ - reference: header.valueType, - name: header.name.name + this.csharp.instantiateClass({ + classReference: this.Types.Headers, + arguments_: [ + this.csharp.dictionary({ + keyType: this.Primitive.string, + valueType: this.Primitive.string, + values: { + type: "entries", + entries: requiredHeaders.map((header) => { + return { + key: this.csharp.codeblock( + this.csharp.string_({ string: header.name.wireValue }) + ), + value: this.stringify({ + reference: header.valueType, + name: header.name.name + }) + }; + }) + } + }) + ] }) ); - writer.endControlFlow(); - } - }), - headerParameterBagReference: this.names.variables.headers - }; + for (const header of optionalHeaders) { + const headerReference = `${this.getParameterName()}.${header.name.name.pascalCase.safeName}`; + writer.controlFlow("if", this.csharp.codeblock(`${headerReference} != null`)); + writer.write(`${this.names.variables.headers}["${header.name.wireValue}"] = `); + writer.writeNodeStatement( + this.stringify({ + reference: header.valueType, + name: header.name.name + }) + ); + writer.endControlFlow(); + } + }), + headerParameterBagReference: this.names.variables.headers + }; + } } public getRequestType(): RawClient.RequestBodyType | undefined { @@ -206,15 +371,32 @@ export class WrappedEndpointRequest extends EndpointRequest { allowOptionals?: boolean; }): ast.CodeBlock { const parameter = parameterOverride ?? `${this.getParameterName()}.${name.pascalCase.safeName}`; + const isOptional = this.isOptional({ typeReference: reference }); + const isNullable = this.isNullable({ typeReference: reference }); + const isStruct = this.isStruct({ typeReference: reference }); + + // Add .Value for nullable structs that need method calls like .ToString(format) + // - When experimental flag is enabled: add .Value for Optional or nullable structs + // - When experimental flag is disabled (legacy): add .Value for optional structs (which become T?) + // Note: strings are reference types and don't need .Value (string? doesn't have .Value) + const needsDotValue = + (allowOptionals ?? true) && + isStruct && + (this.context.generation.settings.enableExplicitNullableOptional + ? (isOptional && isNullable) || isNullable + : isOptional); + const maybeDotValue = needsDotValue ? ".Value" : ""; + if (this.isString(reference)) { - return this.csharp.codeblock(`${parameter}`); + // When using experimental explicit nullable/optional, Optional needs .Value to unwrap + const needsOptionalValue = + (allowOptionals ?? true) && + this.context.generation.settings.enableExplicitNullableOptional && + isOptional && + isNullable; + const optionalValue = needsOptionalValue ? ".Value" : ""; + return this.csharp.codeblock(`${parameter}${optionalValue}`); } - const maybeDotValue = - (this.isOptional({ typeReference: reference }) || this.isNullable({ typeReference: reference })) && - this.isStruct({ typeReference: reference }) && - (allowOptionals ?? true) - ? ".Value" - : ""; if (this.isDateOrDateTime({ type: "datetime", typeReference: reference })) { return this.csharp.codeblock((writer) => { diff --git a/generators/csharp/sdk/src/error/CustomExceptionInterceptorGenerator.ts b/generators/csharp/sdk/src/error/CustomExceptionInterceptorGenerator.ts index 8933e2b89ae7..0e9da815cad3 100644 --- a/generators/csharp/sdk/src/error/CustomExceptionInterceptorGenerator.ts +++ b/generators/csharp/sdk/src/error/CustomExceptionInterceptorGenerator.ts @@ -11,6 +11,26 @@ export class CustomExceptionInterceptorGenerator extends FileGenerator { + writer.writeLine("_clientOptions = clientOptions;"); + }) + }); + class_.addMethod({ name: "Intercept", access: ast.Access.Public, diff --git a/generators/csharp/sdk/src/root-client/RootClientGenerator.ts b/generators/csharp/sdk/src/root-client/RootClientGenerator.ts index 790b10634058..11d124f25257 100644 --- a/generators/csharp/sdk/src/root-client/RootClientGenerator.ts +++ b/generators/csharp/sdk/src/root-client/RootClientGenerator.ts @@ -315,7 +315,7 @@ export class RootClientGenerator extends FileGenerator { + writer.write(`"${baseUrl.replace(/"/g, '\\"')}"`); + }) + }); + } + optionsClass.addField({ origin: optionsClass.explicit("BaseUrl"), access: ast.Access.Public, - override: true, type: this.Primitive.string, summary: "The Websocket URL for the API connection.", get: true, @@ -333,11 +343,11 @@ export class WebSocketClientGenerator extends WithGeneration { accessors: this.hasEnvironments ? { set: (writer: Writer) => { - writer.write(`base.BaseUrl = value`); + writer.write(`_baseUrl = value`); }, get: (writer: Writer) => { writer.writeNode(this.classReference); - writer.write(`.Environments.getBaseUrl(base.BaseUrl)`); + writer.write(`.Environments.getBaseUrl(_baseUrl)`); } } : undefined @@ -353,10 +363,10 @@ export class WebSocketClientGenerator extends WithGeneration { set: true, accessors: { set: (writer: Writer) => { - writer.write(`base.BaseUrl = value`); + writer.write(`_baseUrl = value`); }, get: (writer: Writer) => { - writer.write(`base.BaseUrl`); + writer.write(`_baseUrl`); } } }); @@ -428,7 +438,7 @@ export class WebSocketClientGenerator extends WithGeneration { // }), doc: this.csharp.xmlDocBlockOf({ summary: "Default constructor" }), - baseConstructorCall: this.csharp.invokeMethod({ + thisConstructorCall: this.csharp.invokeMethod({ method: "this", arguments_: [ this.csharp.codeblock((writer) => { @@ -444,61 +454,10 @@ export class WebSocketClientGenerator extends WithGeneration { }; } - /** - * Creates property accessors for query parameters. - * - * Each query parameter becomes a public property that: - * - Gets its value from the ApiOptions - * - Sets its value with change notification - * - * @returns Array of property field definitions - */ - private createPropertyAccessors(cls: ast.Class) { - for (const pathParameter of this.websocketChannel.pathParameters) { - cls.addField({ - origin: pathParameter, - access: ast.Access.Public, - type: this.context.csharpTypeMapper.convert({ - reference: pathParameter.valueType - }), - summary: pathParameter.docs ?? "", - accessors: { - get: (writer) => { - writer.write(`ApiOptions.${pathParameter.name.pascalCase.safeName}`); - }, - set: (writer) => { - writer.write( - `NotifyIfPropertyChanged( EqualityComparer.Default.Equals(ApiOptions.${pathParameter.name.pascalCase.safeName}), ApiOptions.${pathParameter.name.pascalCase.safeName} = value)` - ); - } - } - }); - } - - for (const queryParameter of this.websocketChannel.queryParameters) { - cls.addField({ - origin: queryParameter, - access: ast.Access.Public, - type: this.context.csharpTypeMapper.convert({ - reference: queryParameter.valueType - }), - summary: queryParameter.docs ?? "", - accessors: { - get: (writer) => { - writer.write(`ApiOptions.${queryParameter.name.name.pascalCase.safeName}`); - }, - set: (writer) => { - writer.write( - `NotifyIfPropertyChanged( EqualityComparer.Default.Equals(ApiOptions.${queryParameter.name.name.pascalCase.safeName}), ApiOptions.${queryParameter.name.name.pascalCase.safeName} = value)` - ); - } - } - }); - } - } - /** * Creates a constructor that accepts custom options. + * Initializes _options and _client fields with the WebSocket URI and message handler. + * The URI building logic is inlined directly in the constructor. * * @returns Constructor definition that takes an Options parameter */ @@ -507,51 +466,16 @@ export class WebSocketClientGenerator extends WithGeneration { access: ast.Access.Public, parameters: [this.optionsParameter], body: this.csharp.codeblock((writer) => { - // - }), - doc: this.csharp.xmlDocBlockOf({ summary: "Constructor with options" }), - baseConstructorCall: this.csharp.invokeMethod({ - method: "base", - arguments_: [ - this.csharp.codeblock((writer) => { - writer.write(this.optionsParameter.name); - }) - ] - }) - }; - } + // Initialize _options + writer.writeTextStatement(`_options = ${this.optionsParameter.name}`); - /** - * Creates the CreateUri method that builds the WebSocket connection URL. - * - * This method: - * - Combines the base URL with the channel path - * - Adds query parameters from the options - * - Returns a properly formatted URI for WebSocket connection - * - * @returns The CreateUri method definition - */ - private createCreateUriMethod(cls: ast.Class) { - //- implement CreateUri (creates the Uri for the websocket connection) - //- add sub-path (ie '/chat') - // - add query parameters for all options - - cls.addMethod({ - access: ast.Access.Protected, - override: true, - name: "CreateUri", - return_: this.System.Uri, - parameters: [], - doc: this.csharp.xmlDocBlockOf({ - summary: "Creates the Uri for the websocket connection from the BaseUrl and parameters" - }), - body: this.csharp.codeblock((writer) => { + // Build the URI inline (previously in CreateUri method) const hasQueryParameters = this.websocketChannel.queryParameters.length > 0; writer.write("var uri = "); writer.writeNode( this.System.UriBuilder.new({ - arguments_: [this.csharp.codeblock((writer) => writer.write("BaseUrl"))] + arguments_: [this.csharp.codeblock((writer) => writer.write("_options.BaseUrl"))] }) ); @@ -568,7 +492,7 @@ export class WebSocketClientGenerator extends WithGeneration { writer.pushScope(); for (const queryParameter of this.websocketChannel.queryParameters) { writer.write( - `{ "${queryParameter.name.name.originalName}", ${queryParameter.name.name.pascalCase.safeName} },\n` + `{ "${queryParameter.name.name.originalName}", _options.${queryParameter.name.name.pascalCase.safeName} },\n` ); } writer.popScope(); @@ -592,7 +516,7 @@ export class WebSocketClientGenerator extends WithGeneration { if (pp) { parts.push( this.csharp.codeblock((writer) => - writer.write(`Uri.EscapeDataString(${pp.name.pascalCase.safeName})`) + writer.write(`Uri.EscapeDataString(_options.${pp.name.pascalCase.safeName})`) ) ); } @@ -613,35 +537,19 @@ export class WebSocketClientGenerator extends WithGeneration { writer.writeTextStatement(`"`); } - // return the URI - writer.writeTextStatement("return uri.Uri"); - }) - }); - } - - /** - * Creates the SetConnectionOptions method for configuring WebSocket connection. - * - * This method allows customization of the underlying ClientWebSocketOptions - * before establishing the connection. - * - * @returns The SetConnectionOptions method definition - */ - private createSetConnectionOptionsMethod(cls: ast.Class) { - cls.addMethod({ - access: ast.Access.Protected, - override: true, - name: "SetConnectionOptions", - parameters: [ - this.csharp.parameter({ - name: "options", - type: this.System.Net.WebSockets.ClientWebSocketOptions - }) - ], - body: this.csharp.codeblock((writer) => { - // - }) - }); + // Initialize _client with URI and OnTextMessage handler + writer.write("_client = "); + writer.writeNode( + this.csharp.instantiateClass({ + classReference: this.Types.WebSocketClient, + arguments_: [this.csharp.codeblock("uri.Uri"), this.csharp.codeblock("OnTextMessage")] + }) + ); + writer.writeTextStatement(""); + // Note: PropertyChanged event forwarding is handled by the event's add/remove accessors + }), + doc: this.csharp.xmlDocBlockOf({ summary: "Constructor with options" }) + }; } /** @@ -677,7 +585,7 @@ export class WebSocketClientGenerator extends WithGeneration { for (const oneOfType of type.generics) { result.push({ type: oneOfType, - eventType: this.Types.AsyncEvent(oneOfType), + eventType: this.Types.WebSocketEvent(oneOfType), name: is.ClassReference(oneOfType) ? oneOfType.name : undefined }); } @@ -685,7 +593,7 @@ export class WebSocketClientGenerator extends WithGeneration { // otherwise it's just a single type here result.push({ type, - eventType: this.Types.AsyncEvent(type), + eventType: this.Types.WebSocketEvent(type), name: reference._visit({ container: () => undefined, @@ -724,7 +632,7 @@ export class WebSocketClientGenerator extends WithGeneration { return { reference: each.body.bodyType, type, - eventType: this.Types.AsyncEvent(type), + eventType: this.Types.WebSocketEvent(type), name: bodyType._visit({ container: () => undefined, @@ -753,8 +661,7 @@ export class WebSocketClientGenerator extends WithGeneration { */ private createOnTextMessageMethod(cls: ast.Class) { cls.addMethod({ - access: ast.Access.Protected, - override: true, + access: ast.Access.Private, isAsync: true, name: "OnTextMessage", doc: this.csharp.xmlDocBlockOf({ @@ -817,36 +724,13 @@ export class WebSocketClientGenerator extends WithGeneration { }); } - /** - * Creates the DisposeEvents method for cleaning up event subscriptions. - * - * @returns The DisposeEvents method definition - */ - private createDisposeEventsMethod(cls: ast.Class) { - cls.addMethod({ - access: ast.Access.Protected, - override: true, - name: "DisposeEvents", - doc: this.csharp.xmlDocBlockOf({ - summary: "Disposes of event subscriptions" - }), - parameters: [], - body: this.csharp.codeblock((writer) => { - // - for (const event of this.events) { - writer.writeTextStatement(`${event.name}.Dispose()`); - } - }) - }); - } - /** * Creates Send methods for each client-to-server message type. * * Each method: * - Accepts a strongly-typed message parameter * - Serializes the message to JSON - * - Sends it through the WebSocket connection + * - Sends it through the WebSocket connection via _client.SendInstant * * @returns Array of Send method definitions */ @@ -867,7 +751,7 @@ export class WebSocketClientGenerator extends WithGeneration { }), body: this.csharp.codeblock((writer) => { - writer.writeLine(`await SendInstant(`); + writer.writeLine(`await _client.SendInstant(`); writer.writeNode(this.Types.JsonUtils); writer.writeTextStatement(`.Serialize(message)).ConfigureAwait(false)`); }) @@ -900,35 +784,190 @@ export class WebSocketClientGenerator extends WithGeneration { } } - private createEnvironmentFields(cls: ast.Class) { + /** + * Creates the Status property that forwards to _client.Status. + * Uses expression-bodied property syntax: public ConnectionStatus Status => _client.Status; + */ + private createStatusProperty(cls: ast.Class) { cls.addField({ - origin: cls.explicit("Environment"), + origin: cls.explicit("Status"), access: ast.Access.Public, - type: this.Primitive.string, - summary: "The Environment for the API connection.", + type: this.Types.ConnectionStatus, + summary: "Gets the current connection status of the WebSocket.", + get: true, + initializer: this.csharp.codeblock("_client.Status") + }); + } + + /** + * Creates the ConnectAsync method that forwards to _client.ConnectAsync. + */ + private createConnectAsyncMethod(cls: ast.Class) { + cls.addMethod({ + access: ast.Access.Public, + isAsync: true, + name: "ConnectAsync", + // Note: Don't specify return_ for async void methods - the AST handles Task return type automatically + doc: this.csharp.xmlDocBlockOf({ + summary: "Asynchronously establishes a WebSocket connection." + }), + body: this.csharp.codeblock((writer) => { + writer.writeTextStatement("await _client.ConnectAsync().ConfigureAwait(false)"); + }) + }); + } + + /** + * Creates the CloseAsync method that forwards to _client.CloseAsync. + */ + private createCloseAsyncMethod(cls: ast.Class) { + cls.addMethod({ + access: ast.Access.Public, + isAsync: true, + name: "CloseAsync", + // Note: Don't specify return_ for async void methods - the AST handles Task return type automatically + doc: this.csharp.xmlDocBlockOf({ + summary: "Asynchronously closes the WebSocket connection." + }), + body: this.csharp.codeblock((writer) => { + writer.writeTextStatement("await _client.CloseAsync().ConfigureAwait(false)"); + }) + }); + } + + /** + * Creates the DisposeEvents helper method that disposes all event subscriptions. + */ + private createDisposeEventsMethod(cls: ast.Class) { + cls.addMethod({ + access: ast.Access.Private, + name: "DisposeEvents", + doc: this.csharp.xmlDocBlockOf({ + summary: "Disposes of event subscriptions" + }), + body: this.csharp.codeblock((writer) => { + for (const event of this.events) { + writer.writeTextStatement(`${event.name}.Dispose()`); + } + }) + }); + } + + /** + * Creates the DisposeAsync method for IAsyncDisposable implementation. + * Uses async ValueTask pattern with GC.SuppressFinalize. + */ + private createDisposeAsyncMethod(cls: ast.Class) { + // We use Primitive.Arbitrary to write "async ValueTask" directly as the return type. + // This avoids the AST's Task wrapping that happens when isAsync: true is used. + // The result is: public async ValueTask DisposeAsync() + cls.addMethod({ + access: ast.Access.Public, + name: "DisposeAsync", + return_: this.Types.Arbitrary("async ValueTask"), + doc: this.csharp.xmlDocBlockOf({ + summary: + "Asynchronously disposes the WebSocket client, closing any active connections and cleaning up resources." + }), + body: this.csharp.codeblock((writer) => { + writer.writeLine("await _client.DisposeAsync();"); + writer.writeLine("DisposeEvents();"); + writer.writeTextStatement("GC.SuppressFinalize(this)"); + }) + }); + } + + /** + * Creates the Dispose method for IDisposable implementation. + * Includes GC.SuppressFinalize for proper disposal pattern. + */ + private createDisposeMethod(cls: ast.Class) { + cls.addMethod({ + access: ast.Access.Public, + name: "Dispose", + doc: this.csharp.xmlDocBlockOf({ + summary: + "Synchronously disposes the WebSocket client, closing any active connections and cleaning up resources." + }), + body: this.csharp.codeblock((writer) => { + writer.writeTextStatement("_client.Dispose()"); + writer.writeTextStatement("DisposeEvents()"); + writer.writeTextStatement("GC.SuppressFinalize(this)"); + }) + }); + } + + /** + * Creates the PropertyChanged event for INotifyPropertyChanged implementation. + * The event is forwarded from the internal _client instance. + */ + private createPropertyChangedEvent(cls: ast.Class) { + cls.addNamespaceReference("System.ComponentModel"); + // Add the PropertyChanged event with add/remove accessors (C# event syntax) + cls.addField({ + origin: cls.explicit("PropertyChanged"), + access: ast.Access.Public, + type: this.System.ComponentModel.PropertyChangedEventHandler, + summary: "Event that is raised when a property value changes.", + isEvent: true, accessors: { - get: (writer) => { - writer.write(`ApiOptions.Environment`); + add: (writer) => { + writer.write("_client.PropertyChanged += value"); }, - set: (writer) => { - writer.write( - `NotifyIfPropertyChanged( EqualityComparer.Default.Equals(ApiOptions.Environment), ApiOptions.Environment = value)` - ); + remove: (writer) => { + writer.write("_client.PropertyChanged -= value"); } } }); } + /** + * Creates event forwarders for Connected, Closed, and ExceptionOccurred from _client. + * Uses expression-bodied property syntax: public Event Connected => _client.Connected; + */ + private createClientEventForwarders(cls: ast.Class) { + // Connected event + cls.addField({ + origin: cls.explicit("Connected"), + access: ast.Access.Public, + type: this.Types.WebSocketEvent(this.Types.WebSocketConnected), + summary: "Event that is raised when the WebSocket connection is established.", + get: true, + initializer: this.csharp.codeblock("_client.Connected") + }); + + // Closed event + cls.addField({ + origin: cls.explicit("Closed"), + access: ast.Access.Public, + type: this.Types.WebSocketEvent(this.Types.WebSocketClosed), + summary: "Event that is raised when the WebSocket connection is closed.", + get: true, + initializer: this.csharp.codeblock("_client.Closed") + }); + + // ExceptionOccurred event + cls.addField({ + origin: cls.explicit("ExceptionOccurred"), + access: ast.Access.Public, + type: this.Types.WebSocketEvent(this.System.Exception), + summary: "Event that is raised when an exception occurs during WebSocket operations.", + get: true, + initializer: this.csharp.codeblock("_client.ExceptionOccurred") + }); + } + /** * Creates the complete WebSocket client class. * * Assembles all components into a single class: * - Constructors (default and with options) * - Nested Options class - * - Property accessors for query parameters - * - Core WebSocket methods (CreateUri, SetConnectionOptions, etc.) - * - Event fields for message handling + * - Private _options and _client fields + * - Status property forwarded from _client + * - Event fields forwarded from _client (Connected, Closed, ExceptionOccurred) * - Send methods for outgoing messages + * - IAsyncDisposable, IDisposable, INotifyPropertyChanged implementations * * @returns The complete WebSocket client class definition */ @@ -938,7 +977,26 @@ export class WebSocketClientGenerator extends WithGeneration { access: ast.Access.Public, partial: true, doc: this.websocketChannel.docs ? { summary: this.websocketChannel.docs } : undefined, - parentClassReference: this.Types.AsyncApi(this.optionsClassReference) + interfaceReferences: [ + this.System.IAsyncDisposable, + this.System.IDisposable, + this.System.ComponentModel.INotifyPropertyChanged + ] + }); + + // Add private fields for options and client + cls.addField({ + origin: cls.explicit("_options"), + access: ast.Access.Private, + readonly: true, + type: this.optionsClassReference + }); + + cls.addField({ + origin: cls.explicit("_client"), + access: ast.Access.Private, + readonly: true, + type: this.Types.WebSocketClient }); if (!WebSocketClientGenerator.hasRequiredOptions(this.websocketChannel, this.context)) { @@ -948,11 +1006,28 @@ export class WebSocketClientGenerator extends WithGeneration { cls.addConstructor(this.createConstructorWithOptions()); cls.addNestedClass(this.createOptionsClass()); - this.createPropertyAccessors(cls); - this.createCreateUriMethod(cls); - this.createSetConnectionOptionsMethod(cls); - this.createOnTextMessageMethod(cls); + + // Add Status property forwarded from _client + this.createStatusProperty(cls); + + // Add ConnectAsync and CloseAsync methods + this.createConnectAsyncMethod(cls); + this.createCloseAsyncMethod(cls); + + // Add IAsyncDisposable and IDisposable implementations this.createDisposeEventsMethod(cls); + this.createDisposeAsyncMethod(cls); + this.createDisposeMethod(cls); + + // Add INotifyPropertyChanged implementation + this.createPropertyChangedEvent(cls); + + // Add event fields forwarded from _client + this.createClientEventForwarders(cls); + + // Add OnTextMessage method (private, passed to WebSocketClient constructor) + this.createOnTextMessageMethod(cls); + this.createSendMessageMethods(cls); this.createEventFields(cls); const environmentsClass = this.createEnvironmentsClass(); @@ -960,9 +1035,6 @@ export class WebSocketClientGenerator extends WithGeneration { cls.addNestedClass(environmentsClass); } - if (this.hasEnvironments) { - this.createEnvironmentFields(cls); - } return cls; } diff --git a/generators/csharp/sdk/versions.yml b/generators/csharp/sdk/versions.yml index 3dcfa09eff7e..ed8ebeb3a5de 100644 --- a/generators/csharp/sdk/versions.yml +++ b/generators/csharp/sdk/versions.yml @@ -1,4 +1,77 @@ # yaml-language-server: $schema=../../../fern-versions-yml.schema.json +- version: 2.15.0 + changelogEntry: + - summary: | + Add support for explicit nullable/optional type handling with the new `Optional` type. + + When `experimental-explicit-nullable-optional: true` is configured in generators.yml, the SDK will use `Optional` for nullable optional fields, enabling three-state semantics for PATCH requests: + * **Undefined**: Field not set - won't be included in the request (leave unchanged on server) + * **Defined with null**: Field explicitly set to null - will send `null` (clear the field on server) + * **Defined with value**: Field set to a value - will send the value (update the field on server) + + Example usage: + ```csharp + public class UpdateUserRequest + { + public Optional Name { get; set; } = Optional.Undefined; + public Optional Email { get; set; } = Optional.Undefined; + } + + // Don't send name field (leave unchanged) + var request1 = new UpdateUserRequest(); + + // Set name to a value + var request2 = new UpdateUserRequest { Name = "John" }; + + // Clear name (send null) + var request3 = new UpdateUserRequest { Name = null }; + ``` + + The `Optional` type includes: + * `IsDefined` property to check if a value is set + * `Value` property to access the value (throws if undefined) + * `TryGetValue` method for safe value access + * Implicit conversion operators for ergonomic usage + * Full JSON serialization support + * `IEquatable>` implementation for proper equality checks + type: feat + - summary: | + Fix query parameter serialization to properly handle nullable struct types (DateTime?, DateOnly?, etc.) by adding `.Value` accessor when needed. + type: fix + - summary: | + Improve nullable and optional type handling throughout the generator, including collection value types and type mapping. + type: fix + createdAt: "2026-01-15" + irVersion: 62 + +- version: 2.14.1 + changelogEntry: + - summary: | + When `include-exception-handler: true` is configured, the generated exception interceptor class + now accepts `ClientOptions` in its constructor. This allows the interceptor to access client + configuration when capturing exceptions. + type: fix + createdAt: "2026-01-15" + irVersion: 62 + +- version: 2.14.0 + changelogEntry: + - summary: | + Refactor WebSocket API code generation to use composition over inheritance. + + Key changes: + * Replace `AsyncApi` base class with `WebSocketClient` internal class using composition + * Flatten namespace from `{root}.Core.Async.*` to `{root}.Core.WebSockets` + * Generated WebSocket clients now implement `IAsyncDisposable`, `IDisposable`, and `INotifyPropertyChanged` directly + * Store `Options` and `WebSocketClient` as private fields instead of using inheritance + * Forward `Status`, `Connected`, `Closed`, and `ExceptionOccurred` events from internal client + * Simplify `INotifyPropertyChanged` to only notify for `Status` property changes + + This refactoring improves code clarity and reduces complexity. + type: feat + createdAt: "2026-01-15" + irVersion: 62 + - version: 2.13.1 changelogEntry: - summary: Update Dockerfile to use the latest generator-cli with improve reference.md generation. diff --git a/generators/java-v2/ast/src/custom-config/BaseJavaCustomConfigSchema.ts b/generators/java-v2/ast/src/custom-config/BaseJavaCustomConfigSchema.ts index d5a72b0db1da..3fe2110314ff 100644 --- a/generators/java-v2/ast/src/custom-config/BaseJavaCustomConfigSchema.ts +++ b/generators/java-v2/ast/src/custom-config/BaseJavaCustomConfigSchema.ts @@ -31,6 +31,9 @@ export const BaseJavaCustomConfigSchema = z.object({ "gradle-plugin-management": z.string().optional(), "gradle-central-dependency-management": z.boolean().optional(), + // Hidden options (for debugging). + "enable-gradle-profiling": z.boolean().optional(), + // Deprecated. "wrapped-aliases": z.boolean().optional() }); diff --git a/generators/java-v2/base/src/project/JavaProject.ts b/generators/java-v2/base/src/project/JavaProject.ts index 40cd30014d3e..198668a7b235 100644 --- a/generators/java-v2/base/src/project/JavaProject.ts +++ b/generators/java-v2/base/src/project/JavaProject.ts @@ -2,7 +2,7 @@ import { AbstractProject, File } from "@fern-api/base-generator"; import { AbsoluteFilePath, doesPathExist, join, RelativeFilePath } from "@fern-api/fs-utils"; import { BaseJavaCustomConfigSchema } from "@fern-api/java-ast"; import { loggingExeca } from "@fern-api/logging-execa"; -import { mkdir, writeFile } from "fs/promises"; +import { cp, mkdir, writeFile } from "fs/promises"; import path from "path"; import { AbstractJavaGeneratorContext } from "../context/AbstractJavaGeneratorContext"; @@ -42,12 +42,31 @@ export class JavaProject extends AbstractProject additionalHeaders; private final ClassName builderClassName; private final ClientGeneratorContext clientGeneratorContext; @@ -181,9 +198,13 @@ public GeneratedJavaFile generateFile() { addHeaderBuilder(builderTypeSpec); addHeaderSupplierBuilder(builderTypeSpec); + addQueryParameterBuilder(builderTypeSpec); + addQueryParameterSupplierBuilder(builderTypeSpec); requestOptionsTypeSpec.addField(HEADERS_FIELD); requestOptionsTypeSpec.addField(HEADER_SUPPLIERS_FIELD); + requestOptionsTypeSpec.addField(QUERY_PARAMETERS_FIELD); + requestOptionsTypeSpec.addField(QUERY_PARAMETER_SUPPLIERS_FIELD); builderTypeSpec.addField(HEADERS_FIELD.toBuilder() .initializer(CodeBlock.of("new $T<>()", HashMap.class)) @@ -191,9 +212,17 @@ public GeneratedJavaFile generateFile() { builderTypeSpec.addField(HEADER_SUPPLIERS_FIELD.toBuilder() .initializer(CodeBlock.of("new $T<>()", HashMap.class)) .build()); + builderTypeSpec.addField(QUERY_PARAMETERS_FIELD.toBuilder() + .initializer(CodeBlock.of("new $T<>()", HashMap.class)) + .build()); + builderTypeSpec.addField(QUERY_PARAMETER_SUPPLIERS_FIELD.toBuilder() + .initializer(CodeBlock.of("new $T<>()", HashMap.class)) + .build()); fields.add(new RequestOption(HEADERS_FIELD, HEADERS_FIELD)); fields.add(new RequestOption(HEADER_SUPPLIERS_FIELD, HEADER_SUPPLIERS_FIELD)); + fields.add(new RequestOption(QUERY_PARAMETERS_FIELD, QUERY_PARAMETERS_FIELD)); + fields.add(new RequestOption(QUERY_PARAMETER_SUPPLIERS_FIELD, QUERY_PARAMETER_SUPPLIERS_FIELD)); getHeadersCodeBlock .addStatement("headers.putAll(this.$L)", HEADERS_FIELD.name) @@ -226,6 +255,20 @@ public GeneratedJavaFile generateFile() { .addStatement("return $N", HEADERS_FIELD.name) .returns(HEADERS_FIELD.type) .build()); + requestOptionsTypeSpec.addMethod(MethodSpec.methodBuilder("getQueryParameters") + .addModifiers(Modifier.PUBLIC) + .addStatement( + "$T $N = new $T<>(this.$L)", + QUERY_PARAMETERS_FIELD.type, + QUERY_PARAMETERS_FIELD.name, + HashMap.class, + QUERY_PARAMETERS_FIELD.name) + .beginControlFlow("this.$L.forEach((key, supplier) -> ", QUERY_PARAMETER_SUPPLIERS_FIELD.name) + .addStatement("$N.put(key, supplier.get())", QUERY_PARAMETERS_FIELD.name) + .endControlFlow(")") + .addStatement("return $N", QUERY_PARAMETERS_FIELD.name) + .returns(QUERY_PARAMETERS_FIELD.type) + .build()); requestOptionsTypeSpec.addMethod(MethodSpec.methodBuilder("builder") .addModifiers(Modifier.PUBLIC, Modifier.STATIC) .addStatement("return new $T()", builderClassName) @@ -383,6 +426,28 @@ private void addHeaderSupplierBuilder(TypeSpec.Builder builder) { .build()); } + private void addQueryParameterBuilder(TypeSpec.Builder builder) { + builder.addMethod(MethodSpec.methodBuilder("addQueryParameter") + .addModifiers(Modifier.PUBLIC) + .returns(builderClassName) + .addParameter(String.class, "key") + .addParameter(String.class, "value") + .addStatement("this.$L.put($L, $L)", QUERY_PARAMETERS_FIELD.name, "key", "value") + .addStatement("return this") + .build()); + } + + private void addQueryParameterSupplierBuilder(TypeSpec.Builder builder) { + builder.addMethod(MethodSpec.methodBuilder("addQueryParameter") + .addModifiers(Modifier.PUBLIC) + .returns(builderClassName) + .addParameter(String.class, "key") + .addParameter(ParameterizedTypeName.get(Supplier.class, String.class), "value") + .addStatement("this.$L.put($L, $L)", QUERY_PARAMETER_SUPPLIERS_FIELD.name, "key", "value") + .addStatement("return this") + .build()); + } + private static class RequestOption { private final FieldSpec builderField; private final FieldSpec requestOptionsField; diff --git a/generators/java/sdk/src/main/java/com/fern/java/client/generators/endpoint/HttpUrlBuilder.java b/generators/java/sdk/src/main/java/com/fern/java/client/generators/endpoint/HttpUrlBuilder.java index d8d4c9c9df88..2ad78c0bb789 100644 --- a/generators/java/sdk/src/main/java/com/fern/java/client/generators/endpoint/HttpUrlBuilder.java +++ b/generators/java/sdk/src/main/java/com/fern/java/client/generators/endpoint/HttpUrlBuilder.java @@ -120,12 +120,8 @@ public HttpUrlBuilder( } public GeneratedHttpUrl generateBuilder(List queryParamProperties) { - boolean shouldInline = queryParamProperties.isEmpty() && !hasOptionalPathParams; - if (shouldInline) { - return generateInlineableCodeBlock(); - } else { - return generateUnInlineableCodeBlock(queryParamProperties); - } + // Always use uninlineable code block to support additional query parameters from RequestOptions + return generateUnInlineableCodeBlock(queryParamProperties); } private GeneratedHttpUrl generateInlineableCodeBlock() { @@ -219,6 +215,15 @@ private GeneratedHttpUrl generateUnInlineableCodeBlock(List", + AbstractEndpointWriterVariableNameContext.REQUEST_OPTIONS_PARAMETER_NAME); + codeBlock.addStatement("$L.addQueryParameter(key, value)", httpUrlname); + codeBlock.endControlFlow(")"); + codeBlock.endControlFlow(); return GeneratedHttpUrl.builder() .initialization(codeBlock.build()) .inlineableBuild(CodeBlock.of("$L.build()", httpUrlname)) diff --git a/generators/java/sdk/versions.yml b/generators/java/sdk/versions.yml index a5f337718b5a..0337913544a8 100644 --- a/generators/java/sdk/versions.yml +++ b/generators/java/sdk/versions.yml @@ -1,4 +1,32 @@ # yaml-language-server: $schema=../../../fern-versions-yml.schema.json +- version: 3.31.0 + changelogEntry: + - summary: | + Add `additionalQueryParameters` support to `RequestOptions`, allowing users to add query parameters + to API requests at runtime. Query parameters added via RequestOptions override any request-defined + parameters with the same key. This mirrors the existing `addHeader` pattern. + + Usage example: + ```java + client.endpoint( + Request.builder().build(), + RequestOptions.builder() + .addQueryParameter("key", "value") + .build() + ); + ``` + type: feat + createdAt: "2026-01-15" + irVersion: 63 + +- version: 3.30.0 + changelogEntry: + - summary: | + Add `enable-gradle-profiling` configuration option for profiling Gradle commands during generation. + type: feat + createdAt: "2026-01-15" + irVersion: 63 + - version: 3.29.2 changelogEntry: - summary: | diff --git a/generators/python/poetry.lock b/generators/python/poetry.lock index b778797a1a8e..24214345c220 100644 --- a/generators/python/poetry.lock +++ b/generators/python/poetry.lock @@ -318,14 +318,14 @@ reference = "fern-prod" [[package]] name = "filelock" -version = "3.20.1" +version = "3.20.3" description = "A platform independent file lock." optional = false python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "filelock-3.20.1-py3-none-any.whl", hash = "sha256:15d9e9a67306188a44baa72f569d2bfd803076269365fdea0934385da4dc361a"}, - {file = "filelock-3.20.1.tar.gz", hash = "sha256:b8360948b351b80f420878d8516519a2204b07aefcdcfd24912a5d33127f188c"}, + {file = "filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1"}, + {file = "filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1"}, ] [[package]] @@ -1121,20 +1121,21 @@ typing-extensions = ">=4.12.0" [[package]] name = "virtualenv" -version = "20.30.0" +version = "20.36.1" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ - {file = "virtualenv-20.30.0-py3-none-any.whl", hash = "sha256:e34302959180fca3af42d1800df014b35019490b119eba981af27f2fa486e5d6"}, - {file = "virtualenv-20.30.0.tar.gz", hash = "sha256:800863162bcaa5450a6e4d721049730e7f2dae07720e0902b0e4040bd6f9ada8"}, + {file = "virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f"}, + {file = "virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba"}, ] [package.dependencies] distlib = ">=0.3.7,<1" -filelock = ">=3.12.2,<4" +filelock = {version = ">=3.20.1,<4", markers = "python_version >= \"3.10\""} platformdirs = ">=3.9.1,<5" +typing-extensions = {version = ">=4.13.2", markers = "python_version < \"3.11\""} [package.extras] docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] diff --git a/generators/ruby-v2/base/src/context/AbstractRubyGeneratorContext.ts b/generators/ruby-v2/base/src/context/AbstractRubyGeneratorContext.ts index 6a7f6cb60433..378b5c5e0f9d 100644 --- a/generators/ruby-v2/base/src/context/AbstractRubyGeneratorContext.ts +++ b/generators/ruby-v2/base/src/context/AbstractRubyGeneratorContext.ts @@ -1,12 +1,13 @@ import { AbstractGeneratorContext, FernGeneratorExec, - GeneratorNotificationService + GeneratorNotificationService, + getPackageName } from "@fern-api/browser-compatible-base-generator"; import { RelativeFilePath } from "@fern-api/path-utils"; import { BaseRubyCustomConfigSchema, ruby } from "@fern-api/ruby-ast"; import { IntermediateRepresentation, TypeDeclaration, TypeId } from "@fern-fern/ir-sdk/api"; -import { snakeCase, upperFirst } from "lodash-es"; +import { camelCase, snakeCase, upperFirst } from "lodash-es"; import { RubyProject } from "../project/RubyProject"; import { RubyTypeMapper } from "./RubyTypeMapper"; @@ -48,7 +49,9 @@ export abstract class AbstractRubyGeneratorContext< } public getRootFolderName(): string { - return this.customConfig.module ?? snakeCase(this.config.organization); + // Priority: custom config module > package name from publish config > organization name + const packageName = getPackageName(this.config); + return snakeCase(this.customConfig.module ?? packageName ?? this.config.organization); } public getRootPackageName(): string { @@ -89,8 +92,10 @@ export abstract class AbstractRubyGeneratorContext< } public getRootModuleName(): string { - // Use upperFirst on the organization name directly to avoid snakeCase - return upperFirst(this.customConfig.module ?? this.config.organization); + // Priority: custom config module > package name from publish config > organization name + // Use camelCase to convert hyphenated names (e.g., "nullable-optional") to valid Ruby module names + const packageName = getPackageName(this.config); + return upperFirst(camelCase(this.customConfig.module ?? packageName ?? this.config.organization)); } public getRootModule(): ruby.Module_ { diff --git a/generators/ruby-v2/dynamic-snippets/src/context/DynamicSnippetsGeneratorContext.ts b/generators/ruby-v2/dynamic-snippets/src/context/DynamicSnippetsGeneratorContext.ts index 2765eb512d80..33b59578e2cd 100644 --- a/generators/ruby-v2/dynamic-snippets/src/context/DynamicSnippetsGeneratorContext.ts +++ b/generators/ruby-v2/dynamic-snippets/src/context/DynamicSnippetsGeneratorContext.ts @@ -42,7 +42,7 @@ export class DynamicSnippetsGeneratorContext extends AbstractDynamicSnippetsGene } public getRootClientClassName(): string { - return "Client"; + return this.customConfig?.clientModuleName ?? "Client"; } public getRootModuleName(): string { diff --git a/generators/ruby-v2/sdk/src/SdkGeneratorContext.ts b/generators/ruby-v2/sdk/src/SdkGeneratorContext.ts index 36cc5c2d92af..9dd309cb9555 100644 --- a/generators/ruby-v2/sdk/src/SdkGeneratorContext.ts +++ b/generators/ruby-v2/sdk/src/SdkGeneratorContext.ts @@ -263,7 +263,7 @@ export class SdkGeneratorContext extends AbstractRubyGeneratorContext { @@ -154,7 +156,7 @@ export class ReadmeSnippetBuilder extends AbstractReadmeSnippetBuilder { fullString += "### Environments\n"; fullString += this.writeCode(dedent`${openMardownRubySnippet}require "${this.rootPackageName}" - ${this.rootPackageName} = ${this.rootPackageClientName}::Client.new( + ${this.rootPackageName} = ${this.rootPackageClientName}::${this.rootClientClassName}.new( base_url: ${this.getEnvironmentNameExample()} ) ${closeMardownRubySnippet}`); @@ -164,7 +166,7 @@ export class ReadmeSnippetBuilder extends AbstractReadmeSnippetBuilder { fullString += this.writeCode(dedent`${openMardownRubySnippet}require "${this.rootPackageName}" - ${ReadmeSnippetBuilder.CLIENT_VARIABLE_NAME} = ${this.rootPackageClientName}::Client.new( + ${ReadmeSnippetBuilder.CLIENT_VARIABLE_NAME} = ${this.rootPackageClientName}::${this.rootClientClassName}.new( base_url: ${this.getEnvironmentURLExample()} ) ${closeMardownRubySnippet}`); @@ -175,7 +177,7 @@ export class ReadmeSnippetBuilder extends AbstractReadmeSnippetBuilder { private renderErrorsSnippet(endpoint: EndpointWithFilepath): string { return this.writeCode(dedent`require "${this.rootPackageName}" - ${ReadmeSnippetBuilder.CLIENT_VARIABLE_NAME} = ${this.rootPackageClientName}::Client.new( + ${ReadmeSnippetBuilder.CLIENT_VARIABLE_NAME} = ${this.rootPackageClientName}::${this.rootClientClassName}.new( base_url: ${this.getEnvironmentURLExample()} ) @@ -240,7 +242,7 @@ export class ReadmeSnippetBuilder extends AbstractReadmeSnippetBuilder { private renderRetriesSnippet(endpoint: EndpointWithFilepath): string { return this.writeCode(dedent`require "${this.rootPackageName}" - ${ReadmeSnippetBuilder.CLIENT_VARIABLE_NAME} = ${this.rootPackageClientName}::Client.new( + ${ReadmeSnippetBuilder.CLIENT_VARIABLE_NAME} = ${this.rootPackageClientName}::${this.rootClientClassName}.new( base_url: ${this.getEnvironmentURLExample()}, max_retries: 3 # Configure max retries (default is 2) ) diff --git a/generators/ruby-v2/sdk/versions.yml b/generators/ruby-v2/sdk/versions.yml index a6ad101fa178..3914de49e95d 100644 --- a/generators/ruby-v2/sdk/versions.yml +++ b/generators/ruby-v2/sdk/versions.yml @@ -1,5 +1,23 @@ # yaml-language-server: $schema=../../../fern-versions-yml.schema.json +- version: 1.0.0-rc80 + changelogEntry: + - summary: | + Add support for clientModuleName config option to customize the root client class name. + When clientModuleName is set (e.g., "PinnacleBaseClient"), the generated root client + class will use that name instead of the default "Client". This restores functionality + that existed in the original Ruby SDK generator. + type: fix + - summary: | + Add support for packageName from publish config to set the gem name and module name. + The gem name (folder name) and module name now use the packageName from the RubyGems + publish config (e.g., output.publish.rubygems.packageName) as a fallback when the + custom config module option is not set. This restores functionality from the original + Ruby SDK generator where the gem name could be configured via the publish target. + type: fix + createdAt: "2026-01-15" + irVersion: 61 + - version: 1.0.0-rc79 changelogEntry: - summary: | diff --git a/generators/swift/sdk/src/generators/client/util/__test__/snapshots/formatted-endpoint-paths/webhook-audience.swift b/generators/swift/sdk/src/generators/client/util/__test__/snapshots/formatted-endpoint-paths/webhook-audience.swift new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/generators/typescript/model/type-reference-converters/src/TypeReferenceToStringExpressionConverter.ts b/generators/typescript/model/type-reference-converters/src/TypeReferenceToStringExpressionConverter.ts index 2f75c01380e8..e467430cef23 100644 --- a/generators/typescript/model/type-reference-converters/src/TypeReferenceToStringExpressionConverter.ts +++ b/generators/typescript/model/type-reference-converters/src/TypeReferenceToStringExpressionConverter.ts @@ -37,7 +37,7 @@ export class TypeReferenceToStringExpressionConverter extends AbstractTypeRefere ts.factory.createToken(ts.SyntaxKind.QuestionToken), this.convert(params)(reference), ts.factory.createToken(ts.SyntaxKind.ColonToken), - ts.factory.createIdentifier("null") + ts.factory.createIdentifier("undefined") ); } return (reference) => diff --git a/generators/typescript/sdk/versions.yml b/generators/typescript/sdk/versions.yml index e8e11d727ff9..cd345821310d 100644 --- a/generators/typescript/sdk/versions.yml +++ b/generators/typescript/sdk/versions.yml @@ -1,4 +1,16 @@ # yaml-language-server: $schema=../../../fern-versions-yml.schema.json +- version: 3.43.13 + changelogEntry: + - summary: | + Fix nullable enum query parameters to be dropped when undefined is passed instead of being + sent as null. Previously, nullable query parameters like `role: nullable` would + generate code that converted undefined to null, causing the parameter to be included in the + request as an empty value. Now undefined values remain undefined and are properly dropped + from the query string. + type: fix + createdAt: "2026-01-14" + irVersion: 63 + - version: 3.43.12 changelogEntry: - summary: | diff --git a/package.json b/package.json index 718be2f117db..35f4d975d32b 100644 --- a/package.json +++ b/package.json @@ -138,7 +138,7 @@ "node-fetch@2.x>whatwg-url": "^14.0.0", "qs": "6.14.1", "url-join": "^4.0.1", - "@fern-api/fdr-sdk": "0.142.24-9b6f8eefe", + "@fern-api/fdr-sdk": "0.143.6-48348b476", "es-toolkit": "^1.39.10", "ts-essentials": "^10.1.1", "form-data": "^4.0.4" diff --git a/packages/cli/api-importers/v3-importer-commons/src/AbstractSpecConverter.ts b/packages/cli/api-importers/v3-importer-commons/src/AbstractSpecConverter.ts index 48e49e359c5c..1bc1eb1333a5 100644 --- a/packages/cli/api-importers/v3-importer-commons/src/AbstractSpecConverter.ts +++ b/packages/cli/api-importers/v3-importer-commons/src/AbstractSpecConverter.ts @@ -193,7 +193,12 @@ export abstract class AbstractSpecConverter< Object.entries(ir.websocketChannels ?? {}).filter(([channelId]) => filteredChannels.has(channelId)) ); ir.webhookGroups = Object.fromEntries( - Object.entries(ir.webhookGroups).filter(([webhookId]) => filteredWebhooks.has(webhookId)) + Object.entries(ir.webhookGroups).map(([webhookGroupId, webhookGroup]) => { + const filteredWebhookGroup = webhookGroup.filter( + (webhook) => webhook.id != null && filteredWebhooks.has(webhook.id) + ); + return [webhookGroupId, filteredWebhookGroup]; + }) ); return ir; } diff --git a/packages/cli/api-importers/v3-importer-commons/src/converters/schema/PrimitiveSchemaConverter.ts b/packages/cli/api-importers/v3-importer-commons/src/converters/schema/PrimitiveSchemaConverter.ts index d6d45325087a..943c1b828b34 100644 --- a/packages/cli/api-importers/v3-importer-commons/src/converters/schema/PrimitiveSchemaConverter.ts +++ b/packages/cli/api-importers/v3-importer-commons/src/converters/schema/PrimitiveSchemaConverter.ts @@ -194,11 +194,37 @@ export class PrimitiveSchemaConverter extends AbstractConverter string; +} export class TtyAwareLogger { - private tasks: TaskContextImpl[] = []; + private tasks: Task[] = []; private lastPaint = ""; private spinner = ora({ spinner: "dots11" }); private interval: NodeJS.Timer | undefined; @@ -46,7 +49,7 @@ export class TtyAwareLogger { this.interval = setInterval(this.repaint.bind(this), getSpinnerInterval(this.spinner)); } - public registerTask(context: TaskContextImpl): void { + public registerTask(context: Task): void { this.tasks.push(context); } diff --git a/packages/cli/cli-logger/src/index.ts b/packages/cli/cli-logger/src/index.ts index a555e9e4360d..c8f80f78e422 100644 --- a/packages/cli/cli-logger/src/index.ts +++ b/packages/cli/cli-logger/src/index.ts @@ -1 +1,4 @@ export { formatLog } from "./formatLog"; +export { type Log } from "./Log"; +export { logErrorMessage } from "./logErrorMessage"; +export { TtyAwareLogger } from "./TtyAwareLogger"; diff --git a/packages/cli/cli/src/cli-context/logErrorMessage.ts b/packages/cli/cli-logger/src/logErrorMessage.ts similarity index 100% rename from packages/cli/cli/src/cli-context/logErrorMessage.ts rename to packages/cli/cli-logger/src/logErrorMessage.ts diff --git a/packages/cli/cli-v2/.depcheckrc.json b/packages/cli/cli-v2/.depcheckrc.json new file mode 100644 index 000000000000..99b69556ab6a --- /dev/null +++ b/packages/cli/cli-v2/.depcheckrc.json @@ -0,0 +1,4 @@ +{ + "ignores": [], + "ignore-patterns": ["lib", "dist"] +} diff --git a/packages/cli/cli-v2/package.json b/packages/cli/cli-v2/package.json new file mode 100644 index 000000000000..5fe48e69ebc5 --- /dev/null +++ b/packages/cli/cli-v2/package.json @@ -0,0 +1,49 @@ +{ + "name": "@fern-api/cli-v2", + "version": "0.0.0", + "private": true, + "repository": { + "type": "git", + "url": "https://github.com/fern-api/fern.git", + "directory": "packages/cli/cli-v2" + }, + "sideEffects": false, + "type": "module", + "exports": { + ".": { + "development": "./src/index.ts", + "source": "./src/index.ts", + "types": "./lib/index.d.ts", + "import": "./lib/index.js", + "default": "./lib/index.js" + } + }, + "main": "lib/index.js", + "source": "src/index.ts", + "types": "lib/index.d.ts", + "files": ["lib"], + "scripts": { + "clean": "rm -rf ./lib && rm -rf ./dist && tsc --build --clean", + "compile": "tsc --build", + "compile:debug": "tsc --build --sourceMap", + "depcheck": "depcheck", + "format": "prettier --write --ignore-unknown --ignore-path ../../../shared/.prettierignore \"**\"", + "format:check": "prettier --check --ignore-unknown --ignore-path ../../../shared/.prettierignore \"**\"", + "lint:eslint": "eslint --max-warnings 0 . --ignore-pattern=../../../.eslintignore", + "lint:eslint:fix": "pnpm lint:eslint --fix", + "test": "vitest --passWithNoTests --run", + "test:debug": "pnpm run test --inspect --no-file-parallelism", + "test:update": "vitest --passWithNoTests --run -u" + }, + "devDependencies": { + "@fern-api/cli-logger": "workspace:*", + "@fern-api/configs": "workspace:*", + "@fern-api/logger": "workspace:*", + "@types/node": "18.15.3", + "@types/yargs": "^17.0.28", + "depcheck": "^1.4.7", + "typescript": "5.9.3", + "vitest": "^4.0.8", + "yargs": "^17.4.1" + } +} diff --git a/packages/cli/cli-v2/src/cli.ts b/packages/cli/cli-v2/src/cli.ts new file mode 100644 index 000000000000..0fc86434aad6 --- /dev/null +++ b/packages/cli/cli-v2/src/cli.ts @@ -0,0 +1,29 @@ +import type { Argv } from "yargs"; +import yargs from "yargs"; +import { hideBin } from "yargs/helpers"; +import { addAuthCommand } from "./commands/auth"; +import { GlobalArgs } from "./context/GlobalArgs"; + +export async function runCliV2(argv?: string[]): Promise { + const cli = createCliV2(argv); + await cli.parse(); +} + +function createCliV2(argv?: string[]): Argv { + const cli: Argv = yargs(argv ?? hideBin(process.argv)) + .scriptName("fern") + .version("0.0.1") + .option("log-level", { + type: "string", + description: "Set log level", + choices: ["debug", "info", "warn", "error"] as const, + default: "info" + }) + .strict() + .demandCommand() + .recommendCommands(); + + addAuthCommand(cli); + + return cli; +} diff --git a/packages/cli/cli-v2/src/commands/auth.ts b/packages/cli/cli-v2/src/commands/auth.ts new file mode 100644 index 000000000000..f436e610d1f0 --- /dev/null +++ b/packages/cli/cli-v2/src/commands/auth.ts @@ -0,0 +1,17 @@ +import type { Argv } from "yargs"; +import type { GlobalArgs } from "../context/GlobalArgs"; +import { addLoginCommand } from "./auth/login"; +import { addLogoutCommand } from "./auth/logout"; +import { addTokenCommand } from "./auth/token"; + +export function addAuthCommand(cli: Argv): void { + cli.command("auth", "Authenticate fern", (yargs) => { + addLoginCommand(yargs); + addLogoutCommand(yargs); + addTokenCommand(yargs); + return yargs.demandCommand(1).fail((_msg, _err, yargs) => { + yargs.showHelp(); + process.exit(1); + }); + }); +} diff --git a/packages/cli/cli-v2/src/commands/auth/login.ts b/packages/cli/cli-v2/src/commands/auth/login.ts new file mode 100644 index 000000000000..81d6fdd60d71 --- /dev/null +++ b/packages/cli/cli-v2/src/commands/auth/login.ts @@ -0,0 +1,14 @@ +import type { Argv } from "yargs"; +import { Context } from "../../context/Context"; +import type { GlobalArgs } from "../../context/GlobalArgs"; +import { withContext } from "../../context/withContext"; + +export interface LoginArgs extends GlobalArgs {} + +export function addLoginCommand(cli: Argv): void { + cli.command("login", "Log in to fern", (yargs) => yargs, withContext(handleLogin)); +} + +async function handleLogin(context: Context, _args: LoginArgs): Promise { + context.stdout.info("Logging in..."); +} diff --git a/packages/cli/cli-v2/src/commands/auth/logout.ts b/packages/cli/cli-v2/src/commands/auth/logout.ts new file mode 100644 index 000000000000..23ca283524ed --- /dev/null +++ b/packages/cli/cli-v2/src/commands/auth/logout.ts @@ -0,0 +1,14 @@ +import type { Argv } from "yargs"; +import { Context } from "../../context/Context"; +import type { GlobalArgs } from "../../context/GlobalArgs"; +import { withContext } from "../../context/withContext"; + +export interface LogoutArgs extends GlobalArgs {} + +export function addLogoutCommand(cli: Argv): void { + cli.command("logout", "Log out of fern", (yargs) => yargs, withContext(handleLogout)); +} + +async function handleLogout(context: Context, _args: LogoutArgs): Promise { + context.stdout.info("Logging out..."); +} diff --git a/packages/cli/cli-v2/src/commands/auth/token.ts b/packages/cli/cli-v2/src/commands/auth/token.ts new file mode 100644 index 000000000000..90e3f7bceb7e --- /dev/null +++ b/packages/cli/cli-v2/src/commands/auth/token.ts @@ -0,0 +1,19 @@ +import type { Argv } from "yargs"; +import { Context } from "../../context/Context"; +import type { GlobalArgs } from "../../context/GlobalArgs"; +import { withContext } from "../../context/withContext"; + +export interface TokenArgs extends GlobalArgs {} + +export function addTokenCommand(cli: Argv): void { + cli.command( + "token", + "Print the user's authentication token", + (yargs) => yargs, + withContext(handleToken) + ); +} + +async function handleToken(context: Context, _args: TokenArgs): Promise { + context.stdout.info("unimplemented"); +} diff --git a/packages/cli/cli-v2/src/context/Context.ts b/packages/cli/cli-v2/src/context/Context.ts new file mode 100644 index 000000000000..eddd5ff1a1d9 --- /dev/null +++ b/packages/cli/cli-v2/src/context/Context.ts @@ -0,0 +1,55 @@ +import { Log, TtyAwareLogger } from "@fern-api/cli-logger"; +import { createLogger, LOG_LEVELS, Logger, LogLevel } from "@fern-api/logger"; + +export class Context { + private logLevel: LogLevel; + private ttyAwareLogger: TtyAwareLogger; + public readonly stdout: Logger; + public readonly stderr: Logger; + + constructor({ + stdout, + stderr, + logLevel + }: { + stdout: NodeJS.WriteStream; + stderr: NodeJS.WriteStream; + logLevel?: LogLevel; + }) { + this.logLevel = logLevel ?? LogLevel.Info; + this.ttyAwareLogger = new TtyAwareLogger(stdout, stderr); + this.stdout = createLogger((level: LogLevel, ...args: string[]) => this.log(level, ...args)); + this.stderr = createLogger((level: LogLevel, ...args: string[]) => this.logStderr(level, ...args)); + } + + private log(level: LogLevel, ...parts: string[]) { + this.logImmediately([ + { + parts, + level, + time: new Date() + } + ]); + } + + private logStderr(level: LogLevel, ...parts: string[]) { + this.logImmediately( + [ + { + parts, + level, + time: new Date() + } + ], + { stderr: true } + ); + } + + private logImmediately(logs: Log[], { stderr = false }: { stderr?: boolean } = {}): void { + const filtered = logs.filter((log) => LOG_LEVELS.indexOf(log.level) >= LOG_LEVELS.indexOf(this.logLevel)); + this.ttyAwareLogger.log(filtered, { + includeDebugInfo: this.logLevel === LogLevel.Debug, + stderr + }); + } +} diff --git a/packages/cli/cli-v2/src/context/GlobalArgs.ts b/packages/cli/cli-v2/src/context/GlobalArgs.ts new file mode 100644 index 000000000000..4aa934ac9c2b --- /dev/null +++ b/packages/cli/cli-v2/src/context/GlobalArgs.ts @@ -0,0 +1,3 @@ +export interface GlobalArgs { + "log-level": string; +} diff --git a/packages/cli/cli-v2/src/context/withContext.ts b/packages/cli/cli-v2/src/context/withContext.ts new file mode 100644 index 000000000000..1a50c77b5801 --- /dev/null +++ b/packages/cli/cli-v2/src/context/withContext.ts @@ -0,0 +1,36 @@ +import { LogLevel } from "@fern-api/logger"; +import { Context } from "./Context"; +import type { GlobalArgs } from "./GlobalArgs"; + +export function withContext( + handler: (context: Context, args: T) => Promise +): (args: T) => Promise { + return async (args: T) => { + const context = createContext(args); + return handler(context, args); + }; +} + +function createContext(options: GlobalArgs): Context { + const logLevel = parseLogLevel(options["log-level"] ?? "info"); + return new Context({ + stdout: process.stdout, + stderr: process.stderr, + logLevel + }); +} + +function parseLogLevel(level: string): LogLevel { + switch (level.toLowerCase()) { + case "debug": + return LogLevel.Debug; + case "info": + return LogLevel.Info; + case "warn": + return LogLevel.Warn; + case "error": + return LogLevel.Error; + default: + return LogLevel.Info; + } +} diff --git a/packages/cli/cli-v2/src/index.ts b/packages/cli/cli-v2/src/index.ts new file mode 100644 index 000000000000..8a9e50a9d77c --- /dev/null +++ b/packages/cli/cli-v2/src/index.ts @@ -0,0 +1 @@ +export { runCliV2 } from "./cli"; diff --git a/packages/cli/cli-v2/tsconfig.json b/packages/cli/cli-v2/tsconfig.json new file mode 100644 index 000000000000..e8ce5b072db6 --- /dev/null +++ b/packages/cli/cli-v2/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "@fern-api/configs/tsconfig/main.json", + "compilerOptions": { + "composite": true, + "outDir": "lib", + "rootDir": "src" + }, + "include": ["./src/**/*"], + "references": [ + { + "path": "../cli-logger" + }, + { + "path": "../logger" + } + ] +} diff --git a/packages/cli/cli-v2/vitest.config.ts b/packages/cli/cli-v2/vitest.config.ts new file mode 100644 index 000000000000..efbce2018779 --- /dev/null +++ b/packages/cli/cli-v2/vitest.config.ts @@ -0,0 +1 @@ +export { default } from "@fern-api/configs/vitest/base.mjs"; diff --git a/packages/cli/cli/package.json b/packages/cli/cli/package.json index 59d5283dcfa8..6564dfdf2e55 100644 --- a/packages/cli/cli/package.json +++ b/packages/cli/cli/package.json @@ -56,6 +56,7 @@ "@fern-api/cli-logger": "workspace:*", "@fern-api/cli-migrations": "workspace:*", "@fern-api/cli-source-resolver": "workspace:*", + "@fern-api/cli-v2": "workspace:*", "@fern-api/configs": "workspace:*", "@fern-api/configuration": "workspace:*", "@fern-api/configuration-loader": "workspace:*", @@ -112,7 +113,6 @@ "@types/url-join": "4.0.1", "@types/validate-npm-package-name": "^4.0.0", "@types/yargs": "^17.0.28", - "ansi-escapes": "^5.0.0", "axios": "^1.12.0", "boxen": "^7.1.1", "chalk": "^5.3.0", @@ -125,7 +125,6 @@ "latest-version": "^9.0.0", "lodash-es": "^4.17.21", "openapi-types": "^12.1.3", - "ora": "^7.0.1", "semver": "^7.6.2", "tar": "^6.2.1", "tmp-promise": "^3.0.3", diff --git a/packages/cli/cli/src/cli-context/CliContext.ts b/packages/cli/cli/src/cli-context/CliContext.ts index e6aafe2b3054..56f33414a0e5 100644 --- a/packages/cli/cli/src/cli-context/CliContext.ts +++ b/packages/cli/cli/src/cli-context/CliContext.ts @@ -1,3 +1,4 @@ +import { Log, logErrorMessage, TtyAwareLogger } from "@fern-api/cli-logger"; import { createLogger, LOG_LEVELS, LogLevel } from "@fern-api/logger"; import { getPosthogManager } from "@fern-api/posthog-manager"; import { Project } from "@fern-api/project-loader"; @@ -7,12 +8,8 @@ import { Workspace } from "@fern-api/workspace-loader"; import { input, select } from "@inquirer/prompts"; import chalk from "chalk"; import { maxBy } from "lodash-es"; - import { CliEnvironment } from "./CliEnvironment"; -import { Log } from "./Log"; -import { logErrorMessage } from "./logErrorMessage"; import { TaskContextImpl } from "./TaskContextImpl"; -import { TtyAwareLogger } from "./TtyAwareLogger"; import { getFernUpgradeMessage } from "./upgrade-utils/getFernUpgradeMessage"; import { FernGeneratorUpgradeInfo, getProjectGeneratorUpgrades } from "./upgrade-utils/getGeneratorVersions"; import { getLatestVersionOfCli } from "./upgrade-utils/getLatestVersionOfCli"; diff --git a/packages/cli/cli/src/cli-context/TaskContextImpl.ts b/packages/cli/cli/src/cli-context/TaskContextImpl.ts index b2c4cd61b692..e42277ac8a66 100644 --- a/packages/cli/cli/src/cli-context/TaskContextImpl.ts +++ b/packages/cli/cli/src/cli-context/TaskContextImpl.ts @@ -1,3 +1,4 @@ +import { Log, logErrorMessage } from "@fern-api/cli-logger"; import { addPrefixToString } from "@fern-api/core-utils"; import { createLogger, LogLevel } from "@fern-api/logger"; import { @@ -12,9 +13,6 @@ import { } from "@fern-api/task-context"; import chalk from "chalk"; -import { Log } from "./Log"; -import { logErrorMessage } from "./logErrorMessage"; - export declare namespace TaskContextImpl { export interface Init { logImmediately: (logs: Log[]) => void; diff --git a/packages/cli/cli/src/cli.ts b/packages/cli/cli/src/cli.ts index db257489e302..9b1dc9f63dd6 100644 --- a/packages/cli/cli/src/cli.ts +++ b/packages/cli/cli/src/cli.ts @@ -2,6 +2,7 @@ import type { ReadStream, WriteStream } from "node:tty"; import { fromBinary, toBinary } from "@bufbuild/protobuf"; import { CodeGeneratorRequestSchema, CodeGeneratorResponseSchema } from "@bufbuild/protobuf/wkt"; +import { runCliV2 } from "@fern-api/cli-v2"; import { GENERATORS_CONFIGURATION_FILENAME, generatorsYml, @@ -205,6 +206,7 @@ async function tryRunCli(cliContext: CliContext) { addWriteDocsDefinitionCommand(cli, cliContext); addWriteTranslationCommand(cli, cliContext); addExportCommand(cli, cliContext); + addV2Command(cli, cliContext); // CLI V2 Sanctioned Commands addGetOrganizationCommand(cli, cliContext); @@ -1159,11 +1161,17 @@ function addUpdateApiSpecCommand(cli: Argv, cliContext: CliCon "api update", `Pulls the latest OpenAPI spec from the specified origin in ${GENERATORS_CONFIGURATION_FILENAME} and updates the local spec.`, (yargs) => - yargs.option("api", { - string: true, - description: - "The API to update the spec for. If not specified, all APIs with a declared origin will be updated." - }), + yargs + .option("api", { + string: true, + description: + "The API to update the spec for. If not specified, all APIs with a declared origin will be updated." + }) + .option("indent", { + type: "number", + description: "Indentation width in spaces (default: 2)", + default: 2 + }), async (argv) => { await cliContext.instrumentPostHogEvent({ command: "fern api update" @@ -1173,7 +1181,8 @@ function addUpdateApiSpecCommand(cli: Argv, cliContext: CliCon project: await loadProjectAndRegisterWorkspacesWithContext(cliContext, { commandLineApiWorkspace: argv.api, defaultToAllApiWorkspaces: true - }) + }), + indent: argv.indent }); } ); @@ -1773,6 +1782,11 @@ function addExportCommand(cli: Argv, cliContext: CliContext) { .option("api", { string: true, description: "Only run the command on the provided API" + }) + .option("indent", { + type: "number", + description: "Indentation width in spaces (default: 2)", + default: 2 }), async (argv) => { await cliContext.instrumentPostHogEvent({ @@ -1788,12 +1802,34 @@ function addExportCommand(cli: Argv, cliContext: CliContext) { defaultToAllApiWorkspaces: false }), cliContext, - outputPath: resolve(cwd(), argv.outputPath) + outputPath: resolve(cwd(), argv.outputPath), + indent: argv.indent }); } ); } +function addV2Command(cli: Argv, cliContext: CliContext) { + cli.command( + "v2", + false, // Hidden from --help while in-development. + (yargs) => + yargs.help(false).version(false).strict(false).parserConfiguration({ + "unknown-options-as-args": true + }), + async (argv) => { + try { + // Pass through all arguments after "v2" to the v2 CLI + const v2Args = argv._.slice(1).map(String); + await runCliV2(v2Args); + } catch (error) { + cliContext.logger.error("CLI v2 failed:", String(error)); + cliContext.failWithoutThrowing(); + } + } + ); +} + function addProtocGenFernCommand(cli: Argv, cliContext: CliContext) { cli.command( "protoc-gen-fern", diff --git a/packages/cli/cli/src/commands/export/generateOpenAPIForWorkspaces.ts b/packages/cli/cli/src/commands/export/generateOpenAPIForWorkspaces.ts index a707da40f860..494038573ae6 100644 --- a/packages/cli/cli/src/commands/export/generateOpenAPIForWorkspaces.ts +++ b/packages/cli/cli/src/commands/export/generateOpenAPIForWorkspaces.ts @@ -11,11 +11,13 @@ import { convertIrToOpenApi } from "./convertIrToOpenApi"; export async function generateOpenAPIForWorkspaces({ project, cliContext, - outputPath + outputPath, + indent }: { project: Project; cliContext: CliContext; outputPath: AbsoluteFilePath; + indent: number; }): Promise { await Promise.all( project.apiWorkspaces.map(async (workspace) => { @@ -43,7 +45,9 @@ export async function generateOpenAPIForWorkspaces({ await mkdir(dirname(outputPath), { recursive: true }); await writeFile( outputPath, - outputPath.endsWith(".json") ? JSON.stringify(openapi, undefined, 2) : yaml.dump(openapi) + outputPath.endsWith(".json") + ? JSON.stringify(openapi, undefined, indent) + : yaml.dump(openapi, { indent }) ); }); }) diff --git a/packages/cli/cli/src/commands/upgrade/updateApiSpec.ts b/packages/cli/cli/src/commands/upgrade/updateApiSpec.ts index e6e0c9594a9d..5a031132f641 100644 --- a/packages/cli/cli/src/commands/upgrade/updateApiSpec.ts +++ b/packages/cli/cli/src/commands/upgrade/updateApiSpec.ts @@ -11,7 +11,7 @@ import { ReadableStream } from "stream/web"; import { CliContext } from "../../cli-context/CliContext"; -async function fetchAndWriteFile(url: string, path: string, logger: Logger): Promise { +async function fetchAndWriteFile(url: string, path: string, logger: Logger, indent: number): Promise { const resp = await fetch(url); if (resp.ok && resp.body) { logger.debug("Origin successfully fetched, writing to file"); @@ -22,9 +22,9 @@ async function fetchAndWriteFile(url: string, path: string, logger: Logger): Pro // Read and format file const fileContents = await readFile(path, "utf8"); try { - await writeFile(path, JSON.stringify(JSON.parse(fileContents), undefined, 2), "utf8"); + await writeFile(path, JSON.stringify(JSON.parse(fileContents), undefined, indent), "utf8"); } catch (e) { - await writeFile(path, yaml.dump(yaml.load(fileContents)), "utf8"); + await writeFile(path, yaml.dump(yaml.load(fileContents), { indent }), "utf8"); } logger.debug("File written successfully"); } @@ -32,10 +32,12 @@ async function fetchAndWriteFile(url: string, path: string, logger: Logger): Pro export async function updateApiSpec({ cliContext, - project: { apiWorkspaces } + project: { apiWorkspaces }, + indent }: { cliContext: CliContext; project: Project; + indent: number; }): Promise { // Filter to the specified API, if provided, if not then run through them all for (const workspace of apiWorkspaces) { @@ -63,7 +65,8 @@ export async function updateApiSpec({ await processDefinitions({ cliContext, workspacePath: workspace.absoluteFilePath, - apiLocations: generatorConfig.api.definitions + apiLocations: generatorConfig.api.definitions, + indent }); return; } else if (generatorConfig.api.type === "multiNamespace") { @@ -72,7 +75,8 @@ export async function updateApiSpec({ await processDefinitions({ cliContext, workspacePath: workspace.absoluteFilePath, - apiLocations: generatorConfig.api.rootDefinitions + apiLocations: generatorConfig.api.rootDefinitions, + indent }); } @@ -81,7 +85,8 @@ export async function updateApiSpec({ await processDefinitions({ cliContext, workspacePath: workspace.absoluteFilePath, - apiLocations + apiLocations, + indent }); } } @@ -94,11 +99,13 @@ export async function updateApiSpec({ async function getAndFetchFromAPIDefinitionLocation({ cliContext, workspacePath, - apiLocation + apiLocation, + indent }: { cliContext: CliContext; workspacePath: AbsoluteFilePath; apiLocation: generatorsYml.APIDefinitionLocation; + indent: number; }) { if (apiLocation.schema.type === "protobuf") { cliContext.logger.info("Encountered conjure API definition, skipping API update."); @@ -109,7 +116,8 @@ async function getAndFetchFromAPIDefinitionLocation({ await fetchAndWriteFile( apiLocation.origin, join(workspacePath, RelativeFilePath.of(apiLocation.schema.path)), - cliContext.logger + cliContext.logger, + indent ); } } @@ -117,17 +125,20 @@ async function getAndFetchFromAPIDefinitionLocation({ async function processDefinitions({ cliContext, workspacePath, - apiLocations + apiLocations, + indent }: { cliContext: CliContext; workspacePath: AbsoluteFilePath; apiLocations: generatorsYml.APIDefinitionLocation[]; + indent: number; }) { for (const apiLocation of apiLocations) { await getAndFetchFromAPIDefinitionLocation({ cliContext, workspacePath, - apiLocation + apiLocation, + indent }); } } diff --git a/packages/cli/cli/tsconfig.json b/packages/cli/cli/tsconfig.json index 059c28a1f67f..e7d59f39bfef 100644 --- a/packages/cli/cli/tsconfig.json +++ b/packages/cli/cli/tsconfig.json @@ -130,6 +130,9 @@ { "path": "../cli-logger" }, + { + "path": "../cli-v2" + }, { "path": "../../commons/casings-generator" }, diff --git a/packages/cli/cli/versions.yml b/packages/cli/cli/versions.yml index ecbaf94d41e1..30b4b1119651 100644 --- a/packages/cli/cli/versions.yml +++ b/packages/cli/cli/versions.yml @@ -1,4 +1,55 @@ # yaml-language-server: $schema=../../../fern-versions-yml.schema.json +- version: 3.44.1 + changelogEntry: + - summary: | + Add air-gapped environment detection for AI example enhancement. The CLI now detects network availability before attempting AI enhancement by checking connectivity to Venus. In air-gapped environments, AI enhancement is automatically skipped to prevent network errors. This follows the same pattern used for protobuf generation air-gap detection. + type: fix + createdAt: "2026-01-15" + irVersion: 63 + +- version: 3.44.0 + changelogEntry: + - summary: | + Map OpenAPI validation fields from IR to FDR format. This includes `exclusiveMinimum`, `exclusiveMaximum`, and `multipleOf` for numeric types (integer, double, long, uint, uint64), as well as `minItems`/`maxItems` for list and set types, and `minProperties`/`maxProperties` for map types. + type: feat + createdAt: "2026-01-15" + irVersion: 63 + +- version: 3.43.0 + changelogEntry: + - summary: | + Add --indent flag to 'fern api update' to allow specification of indent size in spaces. + type: feat + - summary: | + Add --indent flag to 'fern export' to allow specification of indent size in spaces. + type: feat + createdAt: "2026-01-15" + irVersion: 63 + +- version: 3.42.4 + changelogEntry: + - summary: | + Update experimental flag options to support `exclude-apis` + type: feat + createdAt: "2026-01-15" + irVersion: 63 + +- version: 3.42.3 + changelogEntry: + - summary: | + Downgrade OpenAPI reference validation to warning severity. Previously, invalid references were treated as errors. + type: fix + createdAt: "2026-01-15" + irVersion: 63 + +- version: 3.42.2 + changelogEntry: + - summary: | + Fix webhook audience filtering in OpenAPI v3 importer. Webhooks with `x-fern-audiences` are now correctly included when generating for matching audiences. + type: fix + createdAt: "2026-01-15" + irVersion: 63 + - version: 3.42.1 changelogEntry: - summary: | diff --git a/packages/cli/configuration-loader/package.json b/packages/cli/configuration-loader/package.json index 3946c72f3000..d06bdc0184e3 100644 --- a/packages/cli/configuration-loader/package.json +++ b/packages/cli/configuration-loader/package.json @@ -34,7 +34,7 @@ "dependencies": { "@fern-api/configuration": "workspace:*", "@fern-api/core-utils": "workspace:*", - "@fern-api/fdr-sdk": "0.142.22-c021dd849", + "@fern-api/fdr-sdk": "0.143.6-48348b476", "@fern-api/fs-utils": "workspace:*", "@fern-api/github": "workspace:*", "@fern-api/task-context": "workspace:*", diff --git a/packages/cli/configuration/package.json b/packages/cli/configuration/package.json index 37a371238168..04b782b050fb 100644 --- a/packages/cli/configuration/package.json +++ b/packages/cli/configuration/package.json @@ -33,7 +33,7 @@ }, "dependencies": { "@fern-api/core-utils": "workspace:*", - "@fern-api/fdr-sdk": "0.142.22-c021dd849", + "@fern-api/fdr-sdk": "0.143.6-48348b476", "@fern-api/fern-definition-schema": "workspace:*", "@fern-api/path-utils": "workspace:*", "@fern-fern/fiddle-sdk": "0.0.738", diff --git a/packages/cli/configuration/src/docs-yml/schemas/sdk/api/resources/docs/types/ExperimentalConfig.ts b/packages/cli/configuration/src/docs-yml/schemas/sdk/api/resources/docs/types/ExperimentalConfig.ts index 647062d725ae..59a1c8613864 100644 --- a/packages/cli/configuration/src/docs-yml/schemas/sdk/api/resources/docs/types/ExperimentalConfig.ts +++ b/packages/cli/configuration/src/docs-yml/schemas/sdk/api/resources/docs/types/ExperimentalConfig.ts @@ -36,4 +36,6 @@ export interface ExperimentalConfig { * DEPRECATED: Use the top-level `ai-example-style-instructions` property instead. */ aiExampleStyleInstructions?: string; + /** Experimental flag to exclude API reference sections from documentation generation. When enabled, API reference content will be omitted from the generated documentation. */ + excludeApis?: boolean; } diff --git a/packages/cli/configuration/src/docs-yml/schemas/sdk/serialization/resources/docs/types/ExperimentalConfig.ts b/packages/cli/configuration/src/docs-yml/schemas/sdk/serialization/resources/docs/types/ExperimentalConfig.ts index 5bda9d00dabb..c7798a584199 100644 --- a/packages/cli/configuration/src/docs-yml/schemas/sdk/serialization/resources/docs/types/ExperimentalConfig.ts +++ b/packages/cli/configuration/src/docs-yml/schemas/sdk/serialization/resources/docs/types/ExperimentalConfig.ts @@ -23,6 +23,7 @@ export const ExperimentalConfig: core.serialization.ObjectSchema< "ai-example-style-instructions", core.serialization.string().optional(), ), + excludeApis: core.serialization.property("exclude-apis", core.serialization.boolean().optional()), }); export declare namespace ExperimentalConfig { @@ -34,5 +35,6 @@ export declare namespace ExperimentalConfig { "dynamic-snippets"?: boolean | null; "ai-examples"?: boolean | null; "ai-example-style-instructions"?: string | null; + "exclude-apis"?: boolean | null; } } diff --git a/packages/cli/docs-importers/commons/package.json b/packages/cli/docs-importers/commons/package.json index 472cba4cbca7..304bd4086f06 100644 --- a/packages/cli/docs-importers/commons/package.json +++ b/packages/cli/docs-importers/commons/package.json @@ -33,7 +33,7 @@ }, "dependencies": { "@fern-api/configuration": "workspace:*", - "@fern-api/fdr-sdk": "0.142.22-c021dd849", + "@fern-api/fdr-sdk": "0.143.6-48348b476", "@fern-api/fs-utils": "workspace:*", "@fern-api/task-context": "workspace:*", "js-yaml": "^4.1.1" diff --git a/packages/cli/docs-importers/mintlify/package.json b/packages/cli/docs-importers/mintlify/package.json index 8a3056e71074..6b5ac2288ae8 100644 --- a/packages/cli/docs-importers/mintlify/package.json +++ b/packages/cli/docs-importers/mintlify/package.json @@ -35,7 +35,7 @@ "@fern-api/configuration": "workspace:*", "@fern-api/core-utils": "workspace:*", "@fern-api/docs-importer-commons": "workspace:*", - "@fern-api/fdr-sdk": "0.142.22-c021dd849", + "@fern-api/fdr-sdk": "0.143.6-48348b476", "@fern-api/fs-utils": "workspace:*", "@fern-api/logger": "workspace:*", "@fern-api/task-context": "workspace:*", diff --git a/packages/cli/docs-importers/readme/package.json b/packages/cli/docs-importers/readme/package.json index ead61da0aced..cf00a73b25dc 100644 --- a/packages/cli/docs-importers/readme/package.json +++ b/packages/cli/docs-importers/readme/package.json @@ -35,7 +35,7 @@ "@fern-api/configuration": "workspace:*", "@fern-api/core-utils": "workspace:*", "@fern-api/docs-importer-commons": "workspace:*", - "@fern-api/fdr-sdk": "0.142.22-c021dd849", + "@fern-api/fdr-sdk": "0.143.6-48348b476", "@fern-api/fs-utils": "workspace:*", "@fern-api/logger": "workspace:*", "@fern-api/task-context": "workspace:*", diff --git a/packages/cli/docs-markdown-utils/package.json b/packages/cli/docs-markdown-utils/package.json index 112f160a1ef5..fc6b033a251f 100644 --- a/packages/cli/docs-markdown-utils/package.json +++ b/packages/cli/docs-markdown-utils/package.json @@ -32,7 +32,7 @@ "test:update": "vitest --run -u" }, "dependencies": { - "@fern-api/fdr-sdk": "0.142.22-c021dd849", + "@fern-api/fdr-sdk": "0.143.6-48348b476", "@fern-api/fs-utils": "workspace:*", "@fern-api/task-context": "workspace:*", "estree-walker": "^3.0.3", diff --git a/packages/cli/docs-preview/package.json b/packages/cli/docs-preview/package.json index 58b55762af03..9198ef0bc8ff 100644 --- a/packages/cli/docs-preview/package.json +++ b/packages/cli/docs-preview/package.json @@ -34,7 +34,7 @@ "dependencies": { "@fern-api/core-utils": "workspace:*", "@fern-api/docs-resolver": "workspace:*", - "@fern-api/fdr-sdk": "0.142.22-c021dd849", + "@fern-api/fdr-sdk": "0.143.6-48348b476", "@fern-api/fs-utils": "workspace:*", "@fern-api/ir-sdk": "workspace:*", "@fern-api/logger": "workspace:*", diff --git a/packages/cli/docs-resolver/package.json b/packages/cli/docs-resolver/package.json index 4f583263a22d..670faa92025b 100644 --- a/packages/cli/docs-resolver/package.json +++ b/packages/cli/docs-resolver/package.json @@ -38,7 +38,7 @@ "@fern-api/core-utils": "workspace:*", "@fern-api/docs-markdown-utils": "workspace:*", "@fern-api/docs-parsers": "0.0.65", - "@fern-api/fdr-sdk": "0.142.22-c021dd849", + "@fern-api/fdr-sdk": "0.143.6-48348b476", "@fern-api/fs-utils": "workspace:*", "@fern-api/ir-generator": "workspace:*", "@fern-api/ir-sdk": "workspace:*", diff --git a/packages/cli/ete-tests/package.json b/packages/cli/ete-tests/package.json index 31f1a534533f..bb57e8347bda 100644 --- a/packages/cli/ete-tests/package.json +++ b/packages/cli/ete-tests/package.json @@ -33,7 +33,7 @@ }, "dependencies": { "@fern-api/configuration": "workspace:*", - "@fern-api/fdr-sdk": "0.142.22-c021dd849", + "@fern-api/fdr-sdk": "0.143.6-48348b476", "@fern-api/fs-utils": "workspace:*", "@fern-api/logger": "workspace:*", "@fern-api/logging-execa": "workspace:*", diff --git a/packages/cli/ete-tests/src/tests/export/__snapshots__/export.test.ts.snap b/packages/cli/ete-tests/src/tests/export/__snapshots__/export.test.ts.snap index 80f95543dd06..04da272e1843 100644 --- a/packages/cli/ete-tests/src/tests/export/__snapshots__/export.test.ts.snap +++ b/packages/cli/ete-tests/src/tests/export/__snapshots__/export.test.ts.snap @@ -1,5 +1,797 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`overrides > simple --indent 8 1`] = ` +"openapi: 3.0.1 +info: + title: api + version: '' + description: foo bar baz +paths: + /test/{rootPathParam}/movies: + post: + operationId: imdb_createMovie + tags: + - Imdb + parameters: + - + name: rootPathParam + in: path + required: true + schema: + type: string + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/MovieId' + '400': + description: '' + content: + application/json: + schema: + oneOf: + - + type: object + properties: + error: + type: string + enum: + - BadRequestError + security: + - + ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateMovieRequest' + /test/{rootPathParam}/movies/{movieId}: + get: + operationId: imdb_getMovie + tags: + - Imdb + parameters: + - + name: rootPathParam + in: path + required: true + schema: + type: string + - + name: movieId + in: path + required: true + schema: + $ref: '#/components/schemas/MovieId' + - + name: movieName + in: query + required: true + schema: + type: array + items: + type: string + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Movie' + '400': + description: '' + content: + application/json: + schema: + oneOf: + - + type: object + properties: + error: + type: string + enum: + - BadRequestError + '404': + description: '' + content: + application/json: + schema: + oneOf: + - + type: object + properties: + error: + type: string + enum: + - NotFoundError + content: + type: string + summary: Get Movie by Id + delete: + operationId: imdb_delete + tags: + - Imdb + parameters: + - + name: rootPathParam + in: path + required: true + schema: + type: string + - + name: movieId + in: path + required: true + schema: + $ref: '#/components/schemas/MovieId' + responses: + '204': + description: '' + '400': + description: '' + content: + application/json: + schema: + oneOf: + - + type: object + properties: + error: + type: string + enum: + - BadRequestError +components: + schemas: + UndiscriminatedUnion: + title: UndiscriminatedUnion + oneOf: + - + type: string + - + type: array + items: + type: string + - + type: integer + - + type: array + items: + type: array + items: + type: integer + Director: + title: Director + type: object + properties: + name: + type: string + age: + $ref: '#/components/schemas/Age' + required: + - name + - age + Age: + title: Age + type: integer + LiteralString: + title: LiteralString + type: string + const: hello + CurrencyAmount: + title: CurrencyAmount + type: string + MovieId: + title: MovieId + type: string + ActorId: + title: ActorId + type: string + Movie: + title: Movie + type: object + properties: + id: + $ref: '#/components/schemas/MovieId' + title: + type: string + rating: + type: number + format: double + required: + - id + - title + - rating + CreateMovieRequest: + title: CreateMovieRequest + type: object + properties: + title: + type: string + ratings: + type: array + items: + type: number + format: double + required: + - title + - ratings + DirectorWrapper: + title: DirectorWrapper + type: object + properties: + director: + $ref: '#/components/schemas/Director' + required: + - director + EmptyObject: + title: EmptyObject + type: object + properties: {} + Person: + title: Person + oneOf: + - + type: object + properties: + type: + type: string + enum: + - actor + value: + $ref: '#/components/schemas/ActorId' + required: + - type + - + type: object + allOf: + - + type: object + properties: + type: + type: string + enum: + - director + - + $ref: '#/components/schemas/Director' + required: + - type + - + type: object + allOf: + - + type: object + properties: + type: + type: string + enum: + - producer + - + $ref: '#/components/schemas/EmptyObject' + required: + - type + - + type: object + allOf: + - + type: object + properties: + type: + type: string + enum: + - cinematographer + - + $ref: '#/components/schemas/EmptyObject' + required: + - type + RecursiveType: + title: RecursiveType + type: object + properties: + selfReferencing: + type: array + items: + $ref: '#/components/schemas/RecursiveType' + required: + - selfReferencing + allOf: + - + $ref: '#/components/schemas/CreateMovieRequest' + securitySchemes: + ApiKeyAuth: + type: apiKey + in: header + name: X_API_KEY +servers: + - + url: https://buildwithfern.com + description: Production + - + url: https://staging.buildwithfern.com + description: Staging +" +`; + +exports[`overrides > simple --indent 8 2`] = ` +"{ + "openapi": "3.0.1", + "info": { + "title": "api", + "version": "", + "description": "foo bar baz" + }, + "paths": { + "/test/{rootPathParam}/movies": { + "post": { + "operationId": "imdb_createMovie", + "tags": [ + "Imdb" + ], + "parameters": [ + { + "name": "rootPathParam", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MovieId" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "properties": { + "error": { + "type": "string", + "enum": [ + "BadRequestError" + ] + } + } + } + ] + } + } + } + } + }, + "security": [ + { + "ApiKeyAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateMovieRequest" + } + } + } + } + } + }, + "/test/{rootPathParam}/movies/{movieId}": { + "get": { + "operationId": "imdb_getMovie", + "tags": [ + "Imdb" + ], + "parameters": [ + { + "name": "rootPathParam", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "movieId", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/MovieId" + } + }, + { + "name": "movieName", + "in": "query", + "required": true, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Movie" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "properties": { + "error": { + "type": "string", + "enum": [ + "BadRequestError" + ] + } + } + } + ] + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "properties": { + "error": { + "type": "string", + "enum": [ + "NotFoundError" + ] + }, + "content": { + "type": "string" + } + } + } + ] + } + } + } + } + }, + "summary": "Get Movie by Id" + }, + "delete": { + "operationId": "imdb_delete", + "tags": [ + "Imdb" + ], + "parameters": [ + { + "name": "rootPathParam", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "movieId", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/MovieId" + } + } + ], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object", + "properties": { + "error": { + "type": "string", + "enum": [ + "BadRequestError" + ] + } + } + } + ] + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "UndiscriminatedUnion": { + "title": "UndiscriminatedUnion", + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "integer" + }, + { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "integer" + } + } + } + ] + }, + "Director": { + "title": "Director", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "age": { + "$ref": "#/components/schemas/Age" + } + }, + "required": [ + "name", + "age" + ] + }, + "Age": { + "title": "Age", + "type": "integer" + }, + "LiteralString": { + "title": "LiteralString", + "type": "string", + "const": "hello" + }, + "CurrencyAmount": { + "title": "CurrencyAmount", + "type": "string" + }, + "MovieId": { + "title": "MovieId", + "type": "string" + }, + "ActorId": { + "title": "ActorId", + "type": "string" + }, + "Movie": { + "title": "Movie", + "type": "object", + "properties": { + "id": { + "$ref": "#/components/schemas/MovieId" + }, + "title": { + "type": "string" + }, + "rating": { + "type": "number", + "format": "double" + } + }, + "required": [ + "id", + "title", + "rating" + ] + }, + "CreateMovieRequest": { + "title": "CreateMovieRequest", + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "ratings": { + "type": "array", + "items": { + "type": "number", + "format": "double" + } + } + }, + "required": [ + "title", + "ratings" + ] + }, + "DirectorWrapper": { + "title": "DirectorWrapper", + "type": "object", + "properties": { + "director": { + "$ref": "#/components/schemas/Director" + } + }, + "required": [ + "director" + ] + }, + "EmptyObject": { + "title": "EmptyObject", + "type": "object", + "properties": {} + }, + "Person": { + "title": "Person", + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "actor" + ] + }, + "value": { + "$ref": "#/components/schemas/ActorId" + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "allOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "director" + ] + } + } + }, + { + "$ref": "#/components/schemas/Director" + } + ], + "required": [ + "type" + ] + }, + { + "type": "object", + "allOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "producer" + ] + } + } + }, + { + "$ref": "#/components/schemas/EmptyObject" + } + ], + "required": [ + "type" + ] + }, + { + "type": "object", + "allOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "cinematographer" + ] + } + } + }, + { + "$ref": "#/components/schemas/EmptyObject" + } + ], + "required": [ + "type" + ] + } + ] + }, + "RecursiveType": { + "title": "RecursiveType", + "type": "object", + "properties": { + "selfReferencing": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RecursiveType" + } + } + }, + "required": [ + "selfReferencing" + ], + "allOf": [ + { + "$ref": "#/components/schemas/CreateMovieRequest" + } + ] + } + }, + "securitySchemes": { + "ApiKeyAuth": { + "type": "apiKey", + "in": "header", + "name": "X_API_KEY" + } + } + }, + "servers": [ + { + "url": "https://buildwithfern.com", + "description": "Production" + }, + { + "url": "https://staging.buildwithfern.com", + "description": "Staging" + } + ] +}" +`; + exports[`overrides > simple 1`] = ` "openapi: 3.0.1 info: diff --git a/packages/cli/ete-tests/src/tests/export/export.test.ts b/packages/cli/ete-tests/src/tests/export/export.test.ts index 2abc119c56a4..a8ddd042b432 100644 --- a/packages/cli/ete-tests/src/tests/export/export.test.ts +++ b/packages/cli/ete-tests/src/tests/export/export.test.ts @@ -8,6 +8,7 @@ const FIXTURES_DIR = path.join(__dirname, "fixtures"); describe("overrides", () => { itFixture("simple"); + itFixtureWithIndent("simple", 8); }); function itFixture(fixtureName: string) { @@ -23,3 +24,17 @@ function itFixture(fixtureName: string) { } }, 90_000); } + +function itFixtureWithIndent(fixtureName: string, indent: number) { + it(// eslint-disable-next-line jest/valid-title + `${fixtureName} --indent ${indent}`, async () => { + const fixturePath = path.join(FIXTURES_DIR, fixtureName); + for (const filename of ["openapi.yml", "openapi.json"]) { + const outputPath = path.join(fixturePath, "output", filename); + await runFernCli(["export", outputPath, "--indent", String(indent)], { + cwd: fixturePath + }); + expect((await readFile(AbsoluteFilePath.of(outputPath))).toString()).toMatchSnapshot(); + } + }, 90_000); +} diff --git a/packages/cli/ete-tests/src/tests/update-api/__snapshots__/update-api.test.ts.snap b/packages/cli/ete-tests/src/tests/update-api/__snapshots__/update-api.test.ts.snap index b46dba23a959..90932c29bba1 100644 --- a/packages/cli/ete-tests/src/tests/update-api/__snapshots__/update-api.test.ts.snap +++ b/packages/cli/ete-tests/src/tests/update-api/__snapshots__/update-api.test.ts.snap @@ -1,5 +1,103 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`fern api update > fern api update --indent 4 1`] = ` +[ + { + "contents": "{ + "version": "*", + "organization": "fern" +}", + "name": "fern.config.json", + "type": "file", + }, + { + "contents": "# yaml-language-server: $schema=https://schema.buildwithfern.dev/generators-yml.json +default-group: local +api: + specs: + - openapi: ./openapi/openapi.json + origin: http://localhost:4567/openapi.json +groups: + local: + generators: + - name: fernapi/fern-typescript-sdk + version: 0.9.5 + config: {} + output: + location: local-file-system + path: ../sdks/typescript +", + "name": "generators.yml", + "type": "file", + }, + { + "contents": [ + { + "contents": "{ + "openapi": "3.0.0", + "info": { + "title": "Test API", + "version": "1.0.0" + }, + "paths": { + "/testdata": { + "get": { + "summary": "Retrieve test data", + "operationId": "getTestData", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/filtered": { + "get": { + "summary": "This endpoint should be filtered out", + "operationId": "filtered", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + } +}", + "name": "openapi.json", + "type": "file", + }, + ], + "name": "openapi", + "type": "directory", + }, +] +`; + exports[`fern api update > fern api update 1`] = ` [ { diff --git a/packages/cli/ete-tests/src/tests/update-api/update-api.test.ts b/packages/cli/ete-tests/src/tests/update-api/update-api.test.ts index 263ba3f849d2..1d73cc1c719e 100644 --- a/packages/cli/ete-tests/src/tests/update-api/update-api.test.ts +++ b/packages/cli/ete-tests/src/tests/update-api/update-api.test.ts @@ -27,4 +27,23 @@ describe("fern api update", () => { // Shutdown the server now that we're done. await cleanup(); }, 60_000); + + it("fern api update --indent 4", async () => { + // Start express server that will respond with the OpenAPI spec. + const { cleanup } = setupOpenAPIServer(); + + const tmpDir = await tmp.dir(); + const directory = AbsoluteFilePath.of(tmpDir.path); + const outputPath = AbsoluteFilePath.of(path.join(directory, "fern")); + + await cp(FIXTURES_DIR, directory, { recursive: true }); + await runFernCli(["api", "update", "--indent", "4"], { + cwd: directory + }); + + expect(await getDirectoryContentsForSnapshot(outputPath)).toMatchSnapshot(); + + // Shutdown the server now that we're done. + await cleanup(); + }, 60_000); }); diff --git a/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/webhook-audience/type__NoAudiencePayload.json b/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/webhook-audience/type__NoAudiencePayload.json new file mode 100644 index 000000000000..5bd30ac09f03 --- /dev/null +++ b/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/webhook-audience/type__NoAudiencePayload.json @@ -0,0 +1,13 @@ +{ + "type": "object", + "properties": { + "data": { + "type": "string" + } + }, + "required": [ + "data" + ], + "additionalProperties": false, + "definitions": {} +} \ No newline at end of file diff --git a/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/webhook-audience/type__PrivatePayload.json b/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/webhook-audience/type__PrivatePayload.json new file mode 100644 index 000000000000..8d6b4397b0d9 --- /dev/null +++ b/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/webhook-audience/type__PrivatePayload.json @@ -0,0 +1,13 @@ +{ + "type": "object", + "properties": { + "secret": { + "type": "string" + } + }, + "required": [ + "secret" + ], + "additionalProperties": false, + "definitions": {} +} \ No newline at end of file diff --git a/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/webhook-audience/type__PublicPayload.json b/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/webhook-audience/type__PublicPayload.json new file mode 100644 index 000000000000..f50ccac10d76 --- /dev/null +++ b/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/webhook-audience/type__PublicPayload.json @@ -0,0 +1,13 @@ +{ + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "additionalProperties": false, + "definitions": {} +} \ No newline at end of file diff --git a/packages/cli/generation/local-generation/local-workspace-runner/src/dynamic-snippets/java/DynamicSnippetsJavaTestGenerator.ts b/packages/cli/generation/local-generation/local-workspace-runner/src/dynamic-snippets/java/DynamicSnippetsJavaTestGenerator.ts index d26f7019f925..bdeb57932722 100644 --- a/packages/cli/generation/local-generation/local-workspace-runner/src/dynamic-snippets/java/DynamicSnippetsJavaTestGenerator.ts +++ b/packages/cli/generation/local-generation/local-workspace-runner/src/dynamic-snippets/java/DynamicSnippetsJavaTestGenerator.ts @@ -5,7 +5,7 @@ import { Config, DynamicSnippetsGenerator } from "@fern-api/java-dynamic-snippet import { loggingExeca } from "@fern-api/logging-execa"; import { TaskContext } from "@fern-api/task-context"; import { FernGeneratorExec } from "@fern-fern/generator-exec-sdk"; -import { mkdir, writeFile } from "fs/promises"; +import { cp, mkdir, writeFile } from "fs/promises"; import path from "path"; import { convertDynamicEndpointSnippetRequest } from "../utils/convertEndpointSnippetRequest"; @@ -74,11 +74,30 @@ export class DynamicSnippetsJavaTestGenerator { const gradlewExists = await doesPathExist(gradlewPath, "file"); if (gradlewExists) { try { - await loggingExeca(this.context.logger, "./gradlew", [":spotlessApply"], { + const customConfig = this.generatorConfig.customConfig as Record | undefined; + const enableProfiling = customConfig?.["enable-gradle-profiling"] === true; + const gradleArgs = [":spotlessApply"]; + if (enableProfiling) { + gradleArgs.push("--profile"); + this.context.logger.info("Running spotlessApply with profiling enabled"); + } + await loggingExeca(this.context.logger, "./gradlew", gradleArgs, { doNotPipeOutput: false, cwd: outputDir }); this.context.logger.debug("Successfully ran spotlessApply"); + if (enableProfiling) { + // Copy build/reports/ to reports/ at the root so it's not gitignored + const buildReportsPath = join(outputDir, RelativeFilePath.of("build/reports")); + const reportsPath = join(outputDir, RelativeFilePath.of("reports")); + const buildReportsExists = await doesPathExist(buildReportsPath, "directory"); + if (buildReportsExists) { + await cp(buildReportsPath, reportsPath, { recursive: true }); + this.context.logger.info("Gradle profiling report copied to reports/"); + } else { + this.context.logger.info("No profiling report found in build/reports/"); + } + } } catch (e) { this.context.failAndThrow("Failed to run spotlessApply", e); } diff --git a/packages/cli/generation/remote-generation/remote-workspace-runner/package.json b/packages/cli/generation/remote-generation/remote-workspace-runner/package.json index 09e1917492f2..34f0cd67ec96 100644 --- a/packages/cli/generation/remote-generation/remote-workspace-runner/package.json +++ b/packages/cli/generation/remote-generation/remote-workspace-runner/package.json @@ -39,7 +39,7 @@ "@fern-api/core-utils": "workspace:*", "@fern-api/docs-resolver": "workspace:*", "@fern-api/fai-sdk": "0.0.6-2ee1b7e28", - "@fern-api/fdr-sdk": "0.142.22-c021dd849", + "@fern-api/fdr-sdk": "0.143.6-48348b476", "@fern-api/fs-utils": "workspace:*", "@fern-api/ir-generator": "workspace:*", "@fern-api/ir-migrations": "workspace:*", diff --git a/packages/cli/generation/remote-generation/remote-workspace-runner/src/publishDocs.ts b/packages/cli/generation/remote-generation/remote-workspace-runner/src/publishDocs.ts index 207ceffbca70..44ace6711222 100644 --- a/packages/cli/generation/remote-generation/remote-workspace-runner/src/publishDocs.ts +++ b/packages/cli/generation/remote-generation/remote-workspace-runner/src/publishDocs.ts @@ -58,6 +58,7 @@ export async function publishDocs({ disableTemplates = false, skipUpload = false, withAiExamples = true, + excludeApis = false, targetAudiences }: { token: FernToken; @@ -74,11 +75,18 @@ export async function publishDocs({ disableTemplates: boolean | undefined; skipUpload: boolean | undefined; withAiExamples?: boolean; + excludeApis?: boolean; targetAudiences?: string[]; }): Promise { const fdr = createFdrService({ token: token.value }); const authConfig: DocsV2Write.AuthConfig = isPrivate ? { type: "private", authType: "sso" } : { type: "public" }; + if (excludeApis) { + context.logger.debug( + "Experimental flag 'exclude-apis' is enabled - API references will be excluded from S3 upload" + ); + } + let docsRegistrationId: string | undefined; let urlToOutput = customDomains[0] ?? domain; const basePath = parseBasePath(domain); @@ -476,7 +484,7 @@ export async function publishDocs({ DocsV1Write.DocsRegistrationId(docsRegistrationId), { docsDefinition, - excludeApis: false, + excludeApis, libraryDocs: libraryDocsConfig } ); diff --git a/packages/cli/generation/remote-generation/remote-workspace-runner/src/runRemoteGenerationForDocsWorkspace.ts b/packages/cli/generation/remote-generation/remote-workspace-runner/src/runRemoteGenerationForDocsWorkspace.ts index 6ab2a51efa3f..67fea61f6e48 100644 --- a/packages/cli/generation/remote-generation/remote-workspace-runner/src/runRemoteGenerationForDocsWorkspace.ts +++ b/packages/cli/generation/remote-generation/remote-workspace-runner/src/runRemoteGenerationForDocsWorkspace.ts @@ -102,6 +102,7 @@ export async function runRemoteGenerationForDocsWorkspace({ skipUpload, withAiExamples: docsWorkspace.config.aiExamples?.enabled ?? docsWorkspace.config.experimental?.aiExamples ?? true, + excludeApis: docsWorkspace.config.experimental?.excludeApis ?? false, targetAudiences: maybeInstance.audiences ? Array.isArray(maybeInstance.audiences) ? maybeInstance.audiences diff --git a/packages/cli/register/package.json b/packages/cli/register/package.json index fb2b2f9f62f5..cadec1966e2d 100644 --- a/packages/cli/register/package.json +++ b/packages/cli/register/package.json @@ -38,7 +38,7 @@ "@fern-api/configuration": "workspace:*", "@fern-api/core": "workspace:*", "@fern-api/core-utils": "workspace:*", - "@fern-api/fdr-sdk": "0.142.22-c021dd849", + "@fern-api/fdr-sdk": "0.143.6-48348b476", "@fern-api/fs-utils": "workspace:*", "@fern-api/ir-generator": "workspace:*", "@fern-api/ir-sdk": "workspace:*", diff --git a/packages/cli/register/src/ai-example-enhancer/lambdaClient.ts b/packages/cli/register/src/ai-example-enhancer/lambdaClient.ts index f6cd0bbfe9e5..6e3ee0cca08d 100644 --- a/packages/cli/register/src/ai-example-enhancer/lambdaClient.ts +++ b/packages/cli/register/src/ai-example-enhancer/lambdaClient.ts @@ -1,4 +1,5 @@ import { FernToken } from "@fern-api/auth"; +import { detectAirGappedMode } from "@fern-api/lazy-fern-workspace"; import { TaskContext } from "@fern-api/task-context"; import { AIExampleEnhancerConfig, ExampleEnhancementRequest, ExampleEnhancementResponse } from "./types"; @@ -102,6 +103,16 @@ export class LambdaExampleEnhancer { }; } + // Check for air-gapped environment before attempting network calls + const isAirGapped = await detectAirGappedMode(`${this.venusOrigin}/health`, this.context.logger); + if (isAirGapped) { + this.context.logger.debug("Skipping AI example enhancement in air-gapped environment"); + return { + enhancedRequestExample: request.originalRequestExample, + enhancedResponseExample: request.originalResponseExample + }; + } + // Fetch JWT from Venus (cached after first call) let jwt: string; try { diff --git a/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/ai-examples-issue-fdr.snap b/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/ai-examples-issue-fdr.snap index e6abdd2d0f9c..c82bb079e3fc 100644 --- a/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/ai-examples-issue-fdr.snap +++ b/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/ai-examples-issue-fdr.snap @@ -247,6 +247,8 @@ "type": "string", }, }, + "maxItems": undefined, + "minItems": undefined, "type": "list", }, "type": "optional", @@ -274,8 +276,11 @@ "type": "primitive", "value": { "default": undefined, + "exclusiveMaximum": undefined, + "exclusiveMinimum": undefined, "maximum": undefined, "minimum": undefined, + "multipleOf": undefined, "type": "double", }, }, @@ -410,6 +415,8 @@ "type": "string", }, }, + "maxItems": undefined, + "minItems": undefined, "type": "list", }, "type": "optional", @@ -451,8 +458,11 @@ "type": "primitive", "value": { "default": undefined, + "exclusiveMaximum": undefined, + "exclusiveMinimum": undefined, "maximum": undefined, "minimum": undefined, + "multipleOf": undefined, "type": "double", }, }, @@ -473,8 +483,11 @@ "type": "primitive", "value": { "default": undefined, + "exclusiveMaximum": undefined, + "exclusiveMinimum": undefined, "maximum": undefined, "minimum": undefined, + "multipleOf": undefined, "type": "integer", }, }, diff --git a/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/auth-scheme-example-selection-fdr.snap b/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/auth-scheme-example-selection-fdr.snap index 6acc2c4236c4..e5187e024732 100644 --- a/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/auth-scheme-example-selection-fdr.snap +++ b/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/auth-scheme-example-selection-fdr.snap @@ -288,8 +288,11 @@ Examples should use the first available auth scheme (admin). "type": "primitive", "value": { "default": undefined, + "exclusiveMaximum": undefined, + "exclusiveMinimum": undefined, "maximum": undefined, "minimum": 1, + "multipleOf": undefined, "type": "double", }, }, diff --git a/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/auth-with-overrides-fdr.snap b/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/auth-with-overrides-fdr.snap index 74cbe64675a2..ad626ac06a65 100644 --- a/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/auth-with-overrides-fdr.snap +++ b/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/auth-with-overrides-fdr.snap @@ -151,6 +151,8 @@ "type": "id", "value": "User", }, + "maxItems": undefined, + "minItems": undefined, "type": "list", }, }, @@ -170,6 +172,8 @@ "type": "id", "value": "User", }, + "maxItems": undefined, + "minItems": undefined, "type": "list", }, }, diff --git a/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/autogen-examples-test-fdr.snap b/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/autogen-examples-test-fdr.snap index ec55cdf7a615..a0424fbe1e89 100644 --- a/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/autogen-examples-test-fdr.snap +++ b/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/autogen-examples-test-fdr.snap @@ -383,6 +383,8 @@ "type": "string", }, }, + "maxItems": undefined, + "minItems": undefined, "type": "list", }, "type": "optional", diff --git a/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/balance-max-null-fdr.snap b/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/balance-max-null-fdr.snap index cbbebaf48d01..ccf3cfb64779 100644 --- a/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/balance-max-null-fdr.snap +++ b/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/balance-max-null-fdr.snap @@ -186,6 +186,8 @@ "type": "id", "value": "RateTier", }, + "maxItems": undefined, + "minItems": undefined, "type": "list", }, }, @@ -309,8 +311,11 @@ "type": "primitive", "value": { "default": undefined, + "exclusiveMaximum": undefined, + "exclusiveMinimum": undefined, "maximum": undefined, "minimum": undefined, + "multipleOf": undefined, "type": "integer", }, }, diff --git a/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/explode-parameter-test-fdr.snap b/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/explode-parameter-test-fdr.snap index d57003a0f355..e9d607277c39 100644 --- a/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/explode-parameter-test-fdr.snap +++ b/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/explode-parameter-test-fdr.snap @@ -232,6 +232,8 @@ "type": "string", }, }, + "maxItems": undefined, + "minItems": undefined, "type": "list", }, "type": "optional", @@ -256,6 +258,8 @@ "type": "string", }, }, + "maxItems": undefined, + "minItems": undefined, "type": "list", }, "type": "optional", @@ -280,6 +284,8 @@ "type": "string", }, }, + "maxItems": undefined, + "minItems": undefined, "type": "list", }, "type": "optional", @@ -304,6 +310,8 @@ "type": "string", }, }, + "maxItems": undefined, + "minItems": undefined, "type": "list", }, "type": "optional", @@ -320,8 +328,11 @@ "type": "primitive", "value": { "default": undefined, + "exclusiveMaximum": undefined, + "exclusiveMinimum": undefined, "maximum": undefined, "minimum": undefined, + "multipleOf": undefined, "type": "integer", }, }, @@ -345,6 +356,8 @@ "type": "id", "value": "Item", }, + "maxItems": undefined, + "minItems": undefined, "type": "list", }, }, @@ -364,6 +377,8 @@ "type": "id", "value": "Item", }, + "maxItems": undefined, + "minItems": undefined, "type": "list", }, }, @@ -589,6 +604,8 @@ "type": "string", }, }, + "maxItems": undefined, + "minItems": undefined, "type": "list", }, "type": "optional", @@ -677,6 +694,8 @@ "type": "string", }, }, + "maxProperties": undefined, + "minProperties": undefined, "type": "map", "valueType": { "type": "unknown", diff --git a/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/grpc-comments-fdr.snap b/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/grpc-comments-fdr.snap index 1298b3739722..b31684f66790 100644 --- a/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/grpc-comments-fdr.snap +++ b/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/grpc-comments-fdr.snap @@ -313,8 +313,11 @@ "type": "primitive", "value": { "default": undefined, + "exclusiveMaximum": undefined, + "exclusiveMinimum": undefined, "maximum": undefined, "minimum": undefined, + "multipleOf": undefined, "type": "long", }, }, diff --git a/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/human-examples-preserved-fdr.snap b/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/human-examples-preserved-fdr.snap index 9fe4b8892b1d..b94d9a0e7712 100644 --- a/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/human-examples-preserved-fdr.snap +++ b/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/human-examples-preserved-fdr.snap @@ -377,8 +377,11 @@ "type": "primitive", "value": { "default": undefined, + "exclusiveMaximum": undefined, + "exclusiveMinimum": undefined, "maximum": undefined, "minimum": undefined, + "multipleOf": undefined, "type": "double", }, }, @@ -456,8 +459,11 @@ "type": "primitive", "value": { "default": undefined, + "exclusiveMaximum": undefined, + "exclusiveMinimum": undefined, "maximum": undefined, "minimum": undefined, + "multipleOf": undefined, "type": "double", }, }, diff --git a/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/min-max-values-fdr.snap b/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/min-max-values-fdr.snap index 49f43f35dd22..8beb01cf16db 100644 --- a/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/min-max-values-fdr.snap +++ b/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/min-max-values-fdr.snap @@ -435,8 +435,11 @@ "type": "primitive", "value": { "default": undefined, + "exclusiveMaximum": undefined, + "exclusiveMinimum": undefined, "maximum": undefined, "minimum": 0, + "multipleOf": undefined, "type": "double", }, }, @@ -452,8 +455,11 @@ "type": "primitive", "value": { "default": undefined, + "exclusiveMaximum": undefined, + "exclusiveMinimum": 0, "maximum": undefined, "minimum": undefined, + "multipleOf": undefined, "type": "double", }, }, @@ -469,8 +475,11 @@ "type": "primitive", "value": { "default": undefined, + "exclusiveMaximum": undefined, + "exclusiveMinimum": undefined, "maximum": 10000, "minimum": 0, + "multipleOf": undefined, "type": "integer", }, }, @@ -486,8 +495,11 @@ "type": "primitive", "value": { "default": undefined, + "exclusiveMaximum": undefined, + "exclusiveMinimum": undefined, "maximum": undefined, "minimum": undefined, + "multipleOf": undefined, "type": "double", }, }, @@ -505,8 +517,11 @@ "type": "primitive", "value": { "default": undefined, + "exclusiveMaximum": 1000, + "exclusiveMinimum": 0, "maximum": undefined, "minimum": undefined, + "multipleOf": undefined, "type": "double", }, }, @@ -532,6 +547,8 @@ "type": "string", }, }, + "maxItems": undefined, + "minItems": undefined, "type": "list", }, "type": "optional", @@ -556,6 +573,8 @@ "type": "string", }, }, + "maxItems": undefined, + "minItems": undefined, "type": "list", }, "type": "optional", @@ -580,6 +599,8 @@ "type": "string", }, }, + "maxProperties": undefined, + "minProperties": undefined, "type": "map", "valueType": { "type": "primitive", @@ -615,6 +636,8 @@ "type": "string", }, }, + "maxProperties": undefined, + "minProperties": undefined, "type": "map", "valueType": { "type": "primitive", @@ -697,6 +720,8 @@ "type": "string", }, }, + "maxItems": undefined, + "minItems": undefined, "type": "list", }, "type": "optional", @@ -779,8 +804,11 @@ "type": "primitive", "value": { "default": undefined, + "exclusiveMaximum": undefined, + "exclusiveMinimum": undefined, "maximum": undefined, "minimum": 0, + "multipleOf": undefined, "type": "double", }, }, @@ -796,8 +824,11 @@ "type": "primitive", "value": { "default": undefined, + "exclusiveMaximum": undefined, + "exclusiveMinimum": 0, "maximum": undefined, "minimum": undefined, + "multipleOf": undefined, "type": "double", }, }, @@ -813,8 +844,11 @@ "type": "primitive", "value": { "default": undefined, + "exclusiveMaximum": undefined, + "exclusiveMinimum": undefined, "maximum": 10000, "minimum": 0, + "multipleOf": undefined, "type": "integer", }, }, @@ -830,8 +864,11 @@ "type": "primitive", "value": { "default": undefined, + "exclusiveMaximum": undefined, + "exclusiveMinimum": undefined, "maximum": undefined, "minimum": undefined, + "multipleOf": undefined, "type": "double", }, }, @@ -849,8 +886,11 @@ "type": "primitive", "value": { "default": undefined, + "exclusiveMaximum": 1000, + "exclusiveMinimum": 0, "maximum": undefined, "minimum": undefined, + "multipleOf": undefined, "type": "double", }, }, @@ -876,6 +916,8 @@ "type": "string", }, }, + "maxItems": undefined, + "minItems": undefined, "type": "list", }, "type": "optional", @@ -900,6 +942,8 @@ "type": "string", }, }, + "maxItems": undefined, + "minItems": undefined, "type": "list", }, "type": "optional", @@ -924,6 +968,8 @@ "type": "string", }, }, + "maxProperties": undefined, + "minProperties": undefined, "type": "map", "valueType": { "type": "primitive", @@ -959,6 +1005,8 @@ "type": "string", }, }, + "maxProperties": undefined, + "minProperties": undefined, "type": "map", "valueType": { "type": "primitive", diff --git a/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/min-max-values-ir.snap b/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/min-max-values-ir.snap index 31c30050f8dd..8a855644a02c 100644 --- a/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/min-max-values-ir.snap +++ b/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/min-max-values-ir.snap @@ -1261,9 +1261,9 @@ "type": "double", "validation": { "exclusiveMax": undefined, - "exclusiveMin": undefined, + "exclusiveMin": true, "max": undefined, - "min": undefined, + "min": 0, "multipleOf": undefined, }, }, @@ -1372,9 +1372,9 @@ "default": undefined, "type": "float", "validation": { - "exclusiveMax": undefined, + "exclusiveMax": true, "exclusiveMin": undefined, - "max": undefined, + "max": 5, "min": 0, "multipleOf": undefined, }, @@ -1432,10 +1432,10 @@ "default": undefined, "type": "double", "validation": { - "exclusiveMax": undefined, - "exclusiveMin": undefined, - "max": undefined, - "min": undefined, + "exclusiveMax": true, + "exclusiveMin": true, + "max": 1000, + "min": 0, "multipleOf": undefined, }, }, @@ -2288,9 +2288,9 @@ "type": "double", "validation": { "exclusiveMax": undefined, - "exclusiveMin": undefined, + "exclusiveMin": true, "max": undefined, - "min": undefined, + "min": 0, "multipleOf": undefined, }, }, @@ -2399,9 +2399,9 @@ "default": undefined, "type": "float", "validation": { - "exclusiveMax": undefined, + "exclusiveMax": true, "exclusiveMin": undefined, - "max": undefined, + "max": 5, "min": 0, "multipleOf": undefined, }, @@ -2459,10 +2459,10 @@ "default": undefined, "type": "double", "validation": { - "exclusiveMax": undefined, - "exclusiveMin": undefined, - "max": undefined, - "min": undefined, + "exclusiveMax": true, + "exclusiveMin": true, + "max": 1000, + "min": 0, "multipleOf": undefined, }, }, diff --git a/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/mixed-examples-test-fdr.snap b/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/mixed-examples-test-fdr.snap index ae1963ad7172..afcc0b9c8c06 100644 --- a/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/mixed-examples-test-fdr.snap +++ b/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/mixed-examples-test-fdr.snap @@ -352,8 +352,11 @@ "type": "primitive", "value": { "default": undefined, + "exclusiveMaximum": undefined, + "exclusiveMinimum": undefined, "maximum": undefined, "minimum": undefined, + "multipleOf": undefined, "type": "double", }, }, diff --git a/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/multiple-security-headers-fdr.snap b/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/multiple-security-headers-fdr.snap index 76fb5220aefe..81bf90df566b 100644 --- a/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/multiple-security-headers-fdr.snap +++ b/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/multiple-security-headers-fdr.snap @@ -586,6 +586,8 @@ "type": "id", "value": "User", }, + "maxItems": undefined, + "minItems": undefined, "type": "list", }, }, @@ -605,6 +607,8 @@ "type": "id", "value": "User", }, + "maxItems": undefined, + "minItems": undefined, "type": "list", }, }, @@ -751,8 +755,11 @@ "type": "primitive", "value": { "default": undefined, + "exclusiveMaximum": undefined, + "exclusiveMinimum": undefined, "maximum": undefined, "minimum": undefined, + "multipleOf": undefined, "type": "double", }, }, diff --git a/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/oneOf-discriminator-fdr.snap b/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/oneOf-discriminator-fdr.snap index b48e288f99db..6ac416b53cc8 100644 --- a/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/oneOf-discriminator-fdr.snap +++ b/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/oneOf-discriminator-fdr.snap @@ -349,8 +349,11 @@ "type": "primitive", "value": { "default": undefined, + "exclusiveMaximum": undefined, + "exclusiveMinimum": undefined, "maximum": undefined, "minimum": undefined, + "multipleOf": undefined, "type": "integer", }, }, diff --git a/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/oneOf-no-discriminator-fdr.snap b/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/oneOf-no-discriminator-fdr.snap index fef1ff471eb3..a7f357a89654 100644 --- a/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/oneOf-no-discriminator-fdr.snap +++ b/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/oneOf-no-discriminator-fdr.snap @@ -270,8 +270,11 @@ "type": "primitive", "value": { "default": undefined, + "exclusiveMaximum": undefined, + "exclusiveMinimum": undefined, "maximum": undefined, "minimum": undefined, + "multipleOf": undefined, "type": "integer", }, }, @@ -382,8 +385,11 @@ "type": "primitive", "value": { "default": undefined, + "exclusiveMaximum": undefined, + "exclusiveMinimum": undefined, "maximum": undefined, "minimum": undefined, + "multipleOf": undefined, "type": "integer", }, }, diff --git a/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/oneOf-references-mapping-fdr.snap b/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/oneOf-references-mapping-fdr.snap index 845ffbe66453..abaff72ccbe0 100644 --- a/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/oneOf-references-mapping-fdr.snap +++ b/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/oneOf-references-mapping-fdr.snap @@ -583,6 +583,8 @@ "type": "string", }, }, + "maxProperties": undefined, + "minProperties": undefined, "type": "map", "valueType": { "type": "primitive", diff --git a/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/oneOf-titled-fdr.snap b/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/oneOf-titled-fdr.snap index 86b96571b313..33db9ed07112 100644 --- a/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/oneOf-titled-fdr.snap +++ b/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/oneOf-titled-fdr.snap @@ -249,8 +249,11 @@ "type": "primitive", "value": { "default": undefined, + "exclusiveMaximum": undefined, + "exclusiveMinimum": undefined, "maximum": undefined, "minimum": undefined, + "multipleOf": undefined, "type": "integer", }, }, @@ -340,8 +343,11 @@ "type": "primitive", "value": { "default": undefined, + "exclusiveMaximum": undefined, + "exclusiveMinimum": undefined, "maximum": undefined, "minimum": undefined, + "multipleOf": undefined, "type": "integer", }, }, diff --git a/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/openapi-from-flag-simple-fdr.snap b/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/openapi-from-flag-simple-fdr.snap index 4faf2c7a8e8b..79a73d86bd22 100644 --- a/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/openapi-from-flag-simple-fdr.snap +++ b/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/openapi-from-flag-simple-fdr.snap @@ -614,6 +614,8 @@ "type": "string", }, }, + "maxProperties": undefined, + "minProperties": undefined, "type": "map", "valueType": { "type": "unknown", @@ -688,6 +690,8 @@ "type": "string", }, }, + "maxProperties": undefined, + "minProperties": undefined, "type": "map", "valueType": { "type": "unknown", @@ -897,6 +901,8 @@ "type": "string", }, }, + "maxProperties": undefined, + "minProperties": undefined, "type": "map", "valueType": { "type": "unknown", diff --git a/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/response-status-code-examples-fdr.snap b/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/response-status-code-examples-fdr.snap index 4c73fe8b9b33..8d50ad2632c5 100644 --- a/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/response-status-code-examples-fdr.snap +++ b/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/response-status-code-examples-fdr.snap @@ -314,8 +314,11 @@ "type": "primitive", "value": { "default": undefined, + "exclusiveMaximum": undefined, + "exclusiveMinimum": undefined, "maximum": undefined, "minimum": undefined, + "multipleOf": undefined, "type": "integer", }, }, diff --git a/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/throttled-error-response-fdr.snap b/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/throttled-error-response-fdr.snap index fcb0782eb5de..3e84623d027b 100644 --- a/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/throttled-error-response-fdr.snap +++ b/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/throttled-error-response-fdr.snap @@ -39,8 +39,11 @@ "type": "primitive", "value": { "default": undefined, + "exclusiveMaximum": undefined, + "exclusiveMinimum": undefined, "maximum": undefined, "minimum": undefined, + "multipleOf": undefined, "type": "double", }, }, diff --git a/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/webhook-multipart-form-data-fdr.snap b/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/webhook-multipart-form-data-fdr.snap new file mode 100644 index 000000000000..f53eda1ea2b5 --- /dev/null +++ b/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/webhook-multipart-form-data-fdr.snap @@ -0,0 +1,221 @@ +{ + "apiName": "Webhook Multipart Form Data Test", + "auth": undefined, + "authSchemes": {}, + "globalHeaders": [], + "navigation": undefined, + "rootPackage": { + "endpoints": [], + "pointsTo": undefined, + "subpackages": [], + "types": [ + "DocumentUploadEvent", + ], + "webhooks": [], + "websockets": [], + }, + "snippetsConfiguration": { + "csharpSdk": undefined, + "goSdk": undefined, + "javaSdk": undefined, + "phpSdk": undefined, + "pythonSdk": undefined, + "rubySdk": undefined, + "rustSdk": undefined, + "swiftSdk": undefined, + "typescriptSdk": undefined, + }, + "subpackages": {}, + "types": { + "DocumentUploadEvent": { + "availability": undefined, + "description": undefined, + "displayName": undefined, + "name": "DocumentUploadEvent", + "shape": { + "extends": [], + "extraProperties": undefined, + "properties": [ + { + "availability": undefined, + "description": "Unique event identifier", + "key": "eventId", + "propertyAccess": undefined, + "valueType": { + "type": "primitive", + "value": { + "default": undefined, + "format": undefined, + "maxLength": undefined, + "minLength": undefined, + "regex": undefined, + "type": "string", + }, + }, + }, + { + "availability": undefined, + "description": "Type of the event", + "key": "eventType", + "propertyAccess": undefined, + "valueType": { + "default": undefined, + "type": "id", + "value": "DocumentUploadEventEventType", + }, + }, + { + "availability": undefined, + "description": "The uploaded document file", + "key": "document", + "propertyAccess": undefined, + "valueType": { + "type": "primitive", + "value": { + "default": undefined, + "format": "binary", + "maxLength": undefined, + "minLength": undefined, + "regex": undefined, + "type": "string", + }, + }, + }, + { + "availability": undefined, + "description": "Optional thumbnail image of the document", + "key": "thumbnail", + "propertyAccess": undefined, + "valueType": { + "defaultValue": undefined, + "itemType": { + "type": "primitive", + "value": { + "default": undefined, + "format": "binary", + "maxLength": undefined, + "minLength": undefined, + "regex": undefined, + "type": "string", + }, + }, + "type": "optional", + }, + }, + { + "availability": undefined, + "description": "JSON-encoded metadata about the document", + "key": "metadata", + "propertyAccess": undefined, + "valueType": { + "defaultValue": undefined, + "itemType": { + "type": "primitive", + "value": { + "default": undefined, + "format": undefined, + "maxLength": undefined, + "minLength": undefined, + "regex": undefined, + "type": "string", + }, + }, + "type": "optional", + }, + }, + { + "availability": undefined, + "description": "User ID who uploaded the document", + "key": "uploadedBy", + "propertyAccess": undefined, + "valueType": { + "type": "primitive", + "value": { + "default": undefined, + "format": undefined, + "maxLength": undefined, + "minLength": undefined, + "regex": undefined, + "type": "string", + }, + }, + }, + { + "availability": undefined, + "description": "Tags associated with the document", + "key": "tags", + "propertyAccess": undefined, + "valueType": { + "defaultValue": undefined, + "itemType": { + "itemType": { + "type": "primitive", + "value": { + "default": undefined, + "format": undefined, + "maxLength": undefined, + "minLength": undefined, + "regex": undefined, + "type": "string", + }, + }, + "maxItems": undefined, + "minItems": undefined, + "type": "list", + }, + "type": "optional", + }, + }, + { + "availability": undefined, + "description": "When the upload occurred", + "key": "timestamp", + "propertyAccess": undefined, + "valueType": { + "type": "primitive", + "value": { + "default": undefined, + "type": "datetime", + }, + }, + }, + { + "availability": undefined, + "description": "Whether the document is private", + "key": "isPrivate", + "propertyAccess": undefined, + "valueType": { + "defaultValue": undefined, + "itemType": { + "type": "primitive", + "value": { + "default": false, + "type": "boolean", + }, + }, + "type": "optional", + }, + }, + ], + "type": "object", + }, + }, + "DocumentUploadEventEventType": { + "availability": undefined, + "description": "Type of the event", + "displayName": undefined, + "name": "DocumentUploadEventEventType", + "shape": { + "default": undefined, + "type": "enum", + "values": [ + { + "availability": undefined, + "description": undefined, + "value": "document.uploaded", + }, + ], + }, + }, + }, +} \ No newline at end of file diff --git a/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/webhook-multipart-form-data-ir.snap b/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/webhook-multipart-form-data-ir.snap new file mode 100644 index 000000000000..d56911399a58 --- /dev/null +++ b/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/webhook-multipart-form-data-ir.snap @@ -0,0 +1,788 @@ +{ + "apiDisplayName": "Webhook Multipart Form Data Test", + "apiDocs": undefined, + "apiName": { + "camelCase": { + "safeName": "webhookMultipartFormDataTest", + "unsafeName": "webhookMultipartFormDataTest", + }, + "originalName": "Webhook Multipart Form Data Test", + "pascalCase": { + "safeName": "WebhookMultipartFormDataTest", + "unsafeName": "WebhookMultipartFormDataTest", + }, + "screamingSnakeCase": { + "safeName": "WEBHOOK_MULTIPART_FORM_DATA_TEST", + "unsafeName": "WEBHOOK_MULTIPART_FORM_DATA_TEST", + }, + "snakeCase": { + "safeName": "webhook_multipart_form_data_test", + "unsafeName": "webhook_multipart_form_data_test", + }, + }, + "apiPlayground": undefined, + "apiVersion": undefined, + "audiences": undefined, + "auth": { + "docs": undefined, + "requirement": "ALL", + "schemes": [], + }, + "basePath": undefined, + "constants": { + "errorInstanceIdKey": { + "name": { + "camelCase": { + "safeName": "errorInstanceId", + "unsafeName": "errorInstanceId", + }, + "originalName": "errorInstanceId", + "pascalCase": { + "safeName": "ErrorInstanceId", + "unsafeName": "ErrorInstanceId", + }, + "screamingSnakeCase": { + "safeName": "ERROR_INSTANCE_ID", + "unsafeName": "ERROR_INSTANCE_ID", + }, + "snakeCase": { + "safeName": "error_instance_id", + "unsafeName": "error_instance_id", + }, + }, + "wireValue": "errorInstanceId", + }, + }, + "dynamic": undefined, + "environments": { + "defaultEnvironment": "Production server", + "environments": { + "_visit": [Function], + "environments": [ + { + "docs": "Production server", + "id": "Production server", + "name": { + "camelCase": { + "safeName": "productionServer", + "unsafeName": "productionServer", + }, + "originalName": "Production server", + "pascalCase": { + "safeName": "ProductionServer", + "unsafeName": "ProductionServer", + }, + "screamingSnakeCase": { + "safeName": "PRODUCTION_SERVER", + "unsafeName": "PRODUCTION_SERVER", + }, + "snakeCase": { + "safeName": "production_server", + "unsafeName": "production_server", + }, + }, + "url": "https://api.example.com/v1", + }, + ], + "type": "singleBaseUrl", + }, + }, + "errorDiscriminationStrategy": { + "_visit": [Function], + "type": "statusCode", + }, + "errors": {}, + "fdrApiDefinitionId": undefined, + "generationMetadata": undefined, + "headers": [], + "idempotencyHeaders": [], + "pathParameters": [], + "publishConfig": undefined, + "readmeConfig": undefined, + "rootPackage": { + "docs": undefined, + "errors": [], + "fernFilepath": { + "allParts": [], + "file": undefined, + "packagePath": [], + }, + "hasEndpointsInTree": false, + "navigationConfig": undefined, + "service": undefined, + "subpackages": [], + "types": [ + "DocumentUploadEvent", + ], + "webhooks": undefined, + "websocket": undefined, + }, + "sdkConfig": { + "hasFileDownloadEndpoints": false, + "hasPaginatedEndpoints": false, + "hasStreamingEndpoints": false, + "isAuthMandatory": true, + "platformHeaders": { + "language": "", + "sdkName": "", + "sdkVersion": "", + "userAgent": undefined, + }, + }, + "selfHosted": false, + "serviceTypeReferenceInfo": { + "sharedTypes": [], + "typesReferencedOnlyByService": {}, + }, + "services": {}, + "sourceConfig": undefined, + "subpackages": {}, + "types": { + "DocumentUploadEvent": { + "autogeneratedExamples": [], + "availability": undefined, + "docs": undefined, + "encoding": undefined, + "inline": false, + "name": { + "displayName": undefined, + "fernFilepath": { + "allParts": [], + "file": undefined, + "packagePath": [], + }, + "name": { + "camelCase": { + "safeName": "documentUploadEvent", + "unsafeName": "documentUploadEvent", + }, + "originalName": "DocumentUploadEvent", + "pascalCase": { + "safeName": "DocumentUploadEvent", + "unsafeName": "DocumentUploadEvent", + }, + "screamingSnakeCase": { + "safeName": "DOCUMENT_UPLOAD_EVENT", + "unsafeName": "DOCUMENT_UPLOAD_EVENT", + }, + "snakeCase": { + "safeName": "document_upload_event", + "unsafeName": "document_upload_event", + }, + }, + "typeId": "DocumentUploadEvent", + }, + "referencedTypes": Set { + "DocumentUploadEventEventType", + }, + "shape": { + "_visit": [Function], + "extendedProperties": [], + "extends": [], + "extraProperties": false, + "properties": [ + { + "availability": undefined, + "docs": "Unique event identifier", + "name": { + "name": { + "camelCase": { + "safeName": "eventId", + "unsafeName": "eventId", + }, + "originalName": "eventId", + "pascalCase": { + "safeName": "EventId", + "unsafeName": "EventId", + }, + "screamingSnakeCase": { + "safeName": "EVENT_ID", + "unsafeName": "EVENT_ID", + }, + "snakeCase": { + "safeName": "event_id", + "unsafeName": "event_id", + }, + }, + "wireValue": "eventId", + }, + "propertyAccess": undefined, + "v2Examples": { + "autogeneratedExamples": {}, + "userSpecifiedExamples": { + "DocumentUploadEventEventId_example_0": "evt_doc_abc123", + }, + }, + "valueType": { + "_visit": [Function], + "primitive": { + "v1": "STRING", + "v2": { + "_visit": [Function], + "default": undefined, + "type": "string", + "validation": { + "format": undefined, + "maxLength": undefined, + "minLength": undefined, + "pattern": undefined, + }, + }, + }, + "type": "primitive", + }, + }, + { + "availability": undefined, + "docs": "Type of the event", + "name": { + "name": { + "camelCase": { + "safeName": "eventType", + "unsafeName": "eventType", + }, + "originalName": "eventType", + "pascalCase": { + "safeName": "EventType", + "unsafeName": "EventType", + }, + "screamingSnakeCase": { + "safeName": "EVENT_TYPE", + "unsafeName": "EVENT_TYPE", + }, + "snakeCase": { + "safeName": "event_type", + "unsafeName": "event_type", + }, + }, + "wireValue": "eventType", + }, + "propertyAccess": undefined, + "v2Examples": { + "autogeneratedExamples": { + "DocumentUploadEventEventType_example_autogenerated": "document.uploaded", + }, + "userSpecifiedExamples": {}, + }, + "valueType": { + "_visit": [Function], + "default": undefined, + "displayName": undefined, + "fernFilepath": { + "allParts": [], + "file": undefined, + "packagePath": [], + }, + "inline": false, + "name": { + "camelCase": { + "safeName": "documentUploadEventEventType", + "unsafeName": "documentUploadEventEventType", + }, + "originalName": "DocumentUploadEventEventType", + "pascalCase": { + "safeName": "DocumentUploadEventEventType", + "unsafeName": "DocumentUploadEventEventType", + }, + "screamingSnakeCase": { + "safeName": "DOCUMENT_UPLOAD_EVENT_EVENT_TYPE", + "unsafeName": "DOCUMENT_UPLOAD_EVENT_EVENT_TYPE", + }, + "snakeCase": { + "safeName": "document_upload_event_event_type", + "unsafeName": "document_upload_event_event_type", + }, + }, + "type": "named", + "typeId": "DocumentUploadEventEventType", + }, + }, + { + "availability": undefined, + "docs": "The uploaded document file", + "name": { + "name": { + "camelCase": { + "safeName": "document", + "unsafeName": "document", + }, + "originalName": "document", + "pascalCase": { + "safeName": "Document", + "unsafeName": "Document", + }, + "screamingSnakeCase": { + "safeName": "DOCUMENT", + "unsafeName": "DOCUMENT", + }, + "snakeCase": { + "safeName": "document", + "unsafeName": "document", + }, + }, + "wireValue": "document", + }, + "propertyAccess": undefined, + "v2Examples": { + "autogeneratedExamples": { + "DocumentUploadEventDocument_example_autogenerated": "string", + }, + "userSpecifiedExamples": {}, + }, + "valueType": { + "_visit": [Function], + "primitive": { + "v1": "STRING", + "v2": { + "_visit": [Function], + "default": undefined, + "type": "string", + "validation": { + "format": "binary", + "maxLength": undefined, + "minLength": undefined, + "pattern": undefined, + }, + }, + }, + "type": "primitive", + }, + }, + { + "availability": undefined, + "docs": "Optional thumbnail image of the document", + "name": { + "name": { + "camelCase": { + "safeName": "thumbnail", + "unsafeName": "thumbnail", + }, + "originalName": "thumbnail", + "pascalCase": { + "safeName": "Thumbnail", + "unsafeName": "Thumbnail", + }, + "screamingSnakeCase": { + "safeName": "THUMBNAIL", + "unsafeName": "THUMBNAIL", + }, + "snakeCase": { + "safeName": "thumbnail", + "unsafeName": "thumbnail", + }, + }, + "wireValue": "thumbnail", + }, + "propertyAccess": undefined, + "v2Examples": { + "autogeneratedExamples": { + "DocumentUploadEventThumbnail_example_autogenerated": "string", + }, + "userSpecifiedExamples": {}, + }, + "valueType": { + "_visit": [Function], + "container": { + "_visit": [Function], + "optional": { + "_visit": [Function], + "primitive": { + "v1": "STRING", + "v2": { + "_visit": [Function], + "default": undefined, + "type": "string", + "validation": { + "format": "binary", + "maxLength": undefined, + "minLength": undefined, + "pattern": undefined, + }, + }, + }, + "type": "primitive", + }, + "type": "optional", + }, + "type": "container", + }, + }, + { + "availability": undefined, + "docs": "JSON-encoded metadata about the document", + "name": { + "name": { + "camelCase": { + "safeName": "metadata", + "unsafeName": "metadata", + }, + "originalName": "metadata", + "pascalCase": { + "safeName": "Metadata", + "unsafeName": "Metadata", + }, + "screamingSnakeCase": { + "safeName": "METADATA", + "unsafeName": "METADATA", + }, + "snakeCase": { + "safeName": "metadata", + "unsafeName": "metadata", + }, + }, + "wireValue": "metadata", + }, + "propertyAccess": undefined, + "v2Examples": { + "autogeneratedExamples": {}, + "userSpecifiedExamples": { + "DocumentUploadEventMetadata_example_0": "{"filename": "contract.pdf", "size": 1024000, "pages": 5}", + }, + }, + "valueType": { + "_visit": [Function], + "container": { + "_visit": [Function], + "optional": { + "_visit": [Function], + "primitive": { + "v1": "STRING", + "v2": { + "_visit": [Function], + "default": undefined, + "type": "string", + "validation": { + "format": undefined, + "maxLength": undefined, + "minLength": undefined, + "pattern": undefined, + }, + }, + }, + "type": "primitive", + }, + "type": "optional", + }, + "type": "container", + }, + }, + { + "availability": undefined, + "docs": "User ID who uploaded the document", + "name": { + "name": { + "camelCase": { + "safeName": "uploadedBy", + "unsafeName": "uploadedBy", + }, + "originalName": "uploadedBy", + "pascalCase": { + "safeName": "UploadedBy", + "unsafeName": "UploadedBy", + }, + "screamingSnakeCase": { + "safeName": "UPLOADED_BY", + "unsafeName": "UPLOADED_BY", + }, + "snakeCase": { + "safeName": "uploaded_by", + "unsafeName": "uploaded_by", + }, + }, + "wireValue": "uploadedBy", + }, + "propertyAccess": undefined, + "v2Examples": { + "autogeneratedExamples": {}, + "userSpecifiedExamples": { + "DocumentUploadEventUploadedBy_example_0": "user_xyz789", + }, + }, + "valueType": { + "_visit": [Function], + "primitive": { + "v1": "STRING", + "v2": { + "_visit": [Function], + "default": undefined, + "type": "string", + "validation": { + "format": undefined, + "maxLength": undefined, + "minLength": undefined, + "pattern": undefined, + }, + }, + }, + "type": "primitive", + }, + }, + { + "availability": undefined, + "docs": "Tags associated with the document", + "name": { + "name": { + "camelCase": { + "safeName": "tags", + "unsafeName": "tags", + }, + "originalName": "tags", + "pascalCase": { + "safeName": "Tags", + "unsafeName": "Tags", + }, + "screamingSnakeCase": { + "safeName": "TAGS", + "unsafeName": "TAGS", + }, + "snakeCase": { + "safeName": "tags", + "unsafeName": "tags", + }, + }, + "wireValue": "tags", + }, + "propertyAccess": undefined, + "v2Examples": { + "autogeneratedExamples": {}, + "userSpecifiedExamples": { + "DocumentUploadEventTags_example_0": [ + "contract", + "legal", + "important", + ], + }, + }, + "valueType": { + "_visit": [Function], + "container": { + "_visit": [Function], + "optional": { + "_visit": [Function], + "container": { + "_visit": [Function], + "list": { + "_visit": [Function], + "primitive": { + "v1": "STRING", + "v2": { + "_visit": [Function], + "default": undefined, + "type": "string", + "validation": { + "format": undefined, + "maxLength": undefined, + "minLength": undefined, + "pattern": undefined, + }, + }, + }, + "type": "primitive", + }, + "type": "list", + }, + "type": "container", + }, + "type": "optional", + }, + "type": "container", + }, + }, + { + "availability": undefined, + "docs": "When the upload occurred", + "name": { + "name": { + "camelCase": { + "safeName": "timestamp", + "unsafeName": "timestamp", + }, + "originalName": "timestamp", + "pascalCase": { + "safeName": "Timestamp", + "unsafeName": "Timestamp", + }, + "screamingSnakeCase": { + "safeName": "TIMESTAMP", + "unsafeName": "TIMESTAMP", + }, + "snakeCase": { + "safeName": "timestamp", + "unsafeName": "timestamp", + }, + }, + "wireValue": "timestamp", + }, + "propertyAccess": undefined, + "v2Examples": { + "autogeneratedExamples": { + "DocumentUploadEventTimestamp_example_autogenerated": "2024-01-15T09:30:00Z", + }, + "userSpecifiedExamples": {}, + }, + "valueType": { + "_visit": [Function], + "primitive": { + "v1": "DATE_TIME", + "v2": { + "_visit": [Function], + "type": "dateTime", + }, + }, + "type": "primitive", + }, + }, + { + "availability": undefined, + "docs": "Whether the document is private", + "name": { + "name": { + "camelCase": { + "safeName": "isPrivate", + "unsafeName": "isPrivate", + }, + "originalName": "isPrivate", + "pascalCase": { + "safeName": "IsPrivate", + "unsafeName": "IsPrivate", + }, + "screamingSnakeCase": { + "safeName": "IS_PRIVATE", + "unsafeName": "IS_PRIVATE", + }, + "snakeCase": { + "safeName": "is_private", + "unsafeName": "is_private", + }, + }, + "wireValue": "isPrivate", + }, + "propertyAccess": undefined, + "v2Examples": { + "autogeneratedExamples": { + "DocumentUploadEventIsPrivate_example_autogenerated": false, + }, + "userSpecifiedExamples": {}, + }, + "valueType": { + "_visit": [Function], + "container": { + "_visit": [Function], + "optional": { + "_visit": [Function], + "primitive": { + "v1": "BOOLEAN", + "v2": { + "_visit": [Function], + "default": false, + "type": "boolean", + }, + }, + "type": "primitive", + }, + "type": "optional", + }, + "type": "container", + }, + }, + ], + "type": "object", + }, + "source": undefined, + "userProvidedExamples": [], + "v2Examples": { + "autogeneratedExamples": { + "DocumentUploadEvent_example_autogenerated": { + "document": "string", + "eventId": "evt_doc_abc123", + "eventType": "document.uploaded", + "timestamp": "2024-01-15T09:30:00Z", + "uploadedBy": "user_xyz789", + }, + }, + "userSpecifiedExamples": {}, + }, + }, + "DocumentUploadEventEventType": { + "autogeneratedExamples": [], + "availability": undefined, + "docs": "Type of the event", + "encoding": undefined, + "inline": false, + "name": { + "displayName": undefined, + "fernFilepath": { + "allParts": [], + "file": undefined, + "packagePath": [], + }, + "name": { + "camelCase": { + "safeName": "documentUploadEventEventType", + "unsafeName": "documentUploadEventEventType", + }, + "originalName": "DocumentUploadEventEventType", + "pascalCase": { + "safeName": "DocumentUploadEventEventType", + "unsafeName": "DocumentUploadEventEventType", + }, + "screamingSnakeCase": { + "safeName": "DOCUMENT_UPLOAD_EVENT_EVENT_TYPE", + "unsafeName": "DOCUMENT_UPLOAD_EVENT_EVENT_TYPE", + }, + "snakeCase": { + "safeName": "document_upload_event_event_type", + "unsafeName": "document_upload_event_event_type", + }, + }, + "typeId": "DocumentUploadEventEventType", + }, + "referencedTypes": Set {}, + "shape": { + "_visit": [Function], + "default": undefined, + "type": "enum", + "values": [ + { + "availability": undefined, + "casing": undefined, + "docs": undefined, + "name": { + "name": { + "camelCase": { + "safeName": "documentUploaded", + "unsafeName": "documentUploaded", + }, + "originalName": "document.uploaded", + "pascalCase": { + "safeName": "DocumentUploaded", + "unsafeName": "DocumentUploaded", + }, + "screamingSnakeCase": { + "safeName": "DOCUMENT_UPLOADED", + "unsafeName": "DOCUMENT_UPLOADED", + }, + "snakeCase": { + "safeName": "document_uploaded", + "unsafeName": "document_uploaded", + }, + }, + "wireValue": "document.uploaded", + }, + }, + ], + }, + "source": undefined, + "userProvidedExamples": [], + "v2Examples": { + "autogeneratedExamples": { + "DocumentUploadEventEventType_example_autogenerated": "document.uploaded", + }, + "userSpecifiedExamples": {}, + }, + }, + }, + "variables": [], + "webhookGroups": {}, + "websocketChannels": undefined, +} \ No newline at end of file diff --git a/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/webhook-openapi-responses-fdr.snap b/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/webhook-openapi-responses-fdr.snap index 0d09ab2b1cfa..e7fe80276091 100644 --- a/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/webhook-openapi-responses-fdr.snap +++ b/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/webhook-openapi-responses-fdr.snap @@ -161,8 +161,11 @@ "type": "primitive", "value": { "default": undefined, + "exclusiveMaximum": undefined, + "exclusiveMinimum": undefined, "maximum": undefined, "minimum": undefined, + "multipleOf": undefined, "type": "double", }, }, diff --git a/packages/cli/register/src/ir-to-fdr-converter/__test__/fixtures/webhook-multipart-form-data/generators.yml b/packages/cli/register/src/ir-to-fdr-converter/__test__/fixtures/webhook-multipart-form-data/generators.yml new file mode 100644 index 000000000000..bc9a333a3500 --- /dev/null +++ b/packages/cli/register/src/ir-to-fdr-converter/__test__/fixtures/webhook-multipart-form-data/generators.yml @@ -0,0 +1,3 @@ +api: + specs: + - openapi: openapi.yml diff --git a/packages/cli/register/src/ir-to-fdr-converter/__test__/fixtures/webhook-multipart-form-data/openapi.yml b/packages/cli/register/src/ir-to-fdr-converter/__test__/fixtures/webhook-multipart-form-data/openapi.yml new file mode 100644 index 000000000000..83a4f0cb7f11 --- /dev/null +++ b/packages/cli/register/src/ir-to-fdr-converter/__test__/fixtures/webhook-multipart-form-data/openapi.yml @@ -0,0 +1,92 @@ +openapi: 3.0.3 +info: + title: Webhook Multipart Form Data Test + version: 1.0.0 + description: | + Test OpenAPI spec for webhook endpoints with multipart/form-data payloads. + + This test captures the current parser behavior with multipart/form-data webhooks. + When multipart support is added, the snapshots will change to show proper parsing. + +servers: + - url: https://api.example.com/v1 + description: Production server + +paths: {} + +webhooks: + documentuploaded: + post: + operationId: documentUploadedWebhook + summary: Document uploaded webhook + description: Webhook with multipart form data including file uploads + tags: + - Document Management + requestBody: + required: true + content: + multipart/form-data: + schema: + $ref: "#/components/schemas/DocumentUploadEvent" + encoding: + document: + contentType: application/pdf, image/png, image/jpeg + thumbnail: + contentType: image/png, image/jpeg + responses: + "200": + description: Webhook processed successfully + "400": + description: Invalid payload or missing required fields + "500": + description: Server error processing webhook + +components: + schemas: + DocumentUploadEvent: + type: object + required: + - eventId + - eventType + - document + - uploadedBy + - timestamp + properties: + eventId: + type: string + description: Unique event identifier + example: "evt_doc_abc123" + eventType: + type: string + enum: [document.uploaded] + description: Type of the event + document: + type: string + format: binary + description: The uploaded document file + thumbnail: + type: string + format: binary + description: Optional thumbnail image of the document + metadata: + type: string + description: JSON-encoded metadata about the document + example: '{"filename": "contract.pdf", "size": 1024000, "pages": 5}' + uploadedBy: + type: string + description: User ID who uploaded the document + example: "user_xyz789" + tags: + type: array + items: + type: string + description: Tags associated with the document + example: ["contract", "legal", "important"] + timestamp: + type: string + format: date-time + description: When the upload occurred + isPrivate: + type: boolean + description: Whether the document is private + default: false diff --git a/packages/cli/register/src/ir-to-fdr-converter/__test__/openapi-from-flag.test.ts b/packages/cli/register/src/ir-to-fdr-converter/__test__/openapi-from-flag.test.ts index 1ed0fe1bae88..f1640fa92ac1 100644 --- a/packages/cli/register/src/ir-to-fdr-converter/__test__/openapi-from-flag.test.ts +++ b/packages/cli/register/src/ir-to-fdr-converter/__test__/openapi-from-flag.test.ts @@ -1952,6 +1952,72 @@ describe("OpenAPI v3 Parser Pipeline (--from-openapi flag)", () => { await expect(intermediateRepresentation).toMatchFileSnapshot("__snapshots__/webhook-openapi-responses-ir.snap"); }); + it("should handle OpenAPI with webhook multipart form data payloads", async () => { + // This test captures current parser behavior with multipart/form-data webhooks. + // When multipart support is added, the snapshots will change to show proper parsing. + const context = createMockTaskContext(); + const workspace = await loadAPIWorkspace({ + absolutePathToWorkspace: join( + AbsoluteFilePath.of(__dirname), + RelativeFilePath.of("fixtures/webhook-multipart-form-data") + ), + context, + cliVersion: "0.0.0", + workspaceName: "webhook-multipart-form-data" + }); + + expect(workspace.didSucceed).toBe(true); + assert(workspace.didSucceed); + + if (!(workspace.workspace instanceof OSSWorkspace)) { + throw new Error( + `Expected OSSWorkspace for OpenAPI processing, got ${workspace.workspace.constructor.name}` + ); + } + + const intermediateRepresentation = await workspace.workspace.getIntermediateRepresentation({ + context, + audiences: { type: "all" }, + enableUniqueErrorsPerEndpoint: true, + generateV1Examples: false, + logWarnings: false + }); + + const fdrApiDefinition = await convertIrToFdrApi({ + ir: intermediateRepresentation, + snippetsConfig: { + typescriptSdk: undefined, + pythonSdk: undefined, + javaSdk: undefined, + rubySdk: undefined, + goSdk: undefined, + csharpSdk: undefined, + phpSdk: undefined, + swiftSdk: undefined, + rustSdk: undefined + }, + playgroundConfig: { + oauth: true + }, + context + }); + + // Validate basic IR structure (webhooks with multipart/form-data may not parse yet) + expect(intermediateRepresentation).toBeDefined(); + expect(intermediateRepresentation.webhookGroups).toBeDefined(); + // Note: webhookGroups may be empty if multipart/form-data isn't supported yet + + // Validate FDR output structure + expect(fdrApiDefinition.types).toBeDefined(); + expect(fdrApiDefinition.rootPackage).toBeDefined(); + + // Snapshot the output for regression testing + await expect(fdrApiDefinition).toMatchFileSnapshot("__snapshots__/webhook-multipart-form-data-fdr.snap"); + await expect(intermediateRepresentation).toMatchFileSnapshot( + "__snapshots__/webhook-multipart-form-data-ir.snap" + ); + }); + it("should handle OpenAPI with nullable balance_max in tiered rates", async () => { const context = createMockTaskContext(); const workspace = await loadAPIWorkspace({ diff --git a/packages/cli/register/src/ir-to-fdr-converter/convertTypeShape.ts b/packages/cli/register/src/ir-to-fdr-converter/convertTypeShape.ts index e21d1e634d25..a0120dd31bf4 100644 --- a/packages/cli/register/src/ir-to-fdr-converter/convertTypeShape.ts +++ b/packages/cli/register/src/ir-to-fdr-converter/convertTypeShape.ts @@ -134,14 +134,18 @@ export function convertTypeReference(irTypeReference: Ir.types.TypeReference): F list: (itemType) => { return { type: "list", - itemType: convertTypeReference(itemType) + itemType: convertTypeReference(itemType), + minItems: undefined, + maxItems: undefined }; }, map: ({ keyType, valueType }) => { return { type: "map", keyType: convertTypeReference(keyType), - valueType: convertTypeReference(valueType) + valueType: convertTypeReference(valueType), + minProperties: undefined, + maxProperties: undefined }; }, optional: (itemType) => { @@ -160,7 +164,9 @@ export function convertTypeReference(irTypeReference: Ir.types.TypeReference): F set: (itemType) => { return { type: "set", - itemType: convertTypeReference(itemType) + itemType: convertTypeReference(itemType), + minItems: undefined, + maxItems: undefined }; }, literal: (literal) => { @@ -219,12 +225,7 @@ export function convertTypeReference(irTypeReference: Ir.types.TypeReference): F return convertString(primitive.v2); }, long: () => { - return { - type: "long", - default: primitive.v2?.type === "long" ? primitive.v2.default : undefined, - minimum: undefined, - maximum: undefined - }; + return convertLong(primitive.v2); }, boolean: () => { return { @@ -264,14 +265,10 @@ export function convertTypeReference(irTypeReference: Ir.types.TypeReference): F }; }, uint: () => { - return { - type: "uint" - }; + return convertUint(primitive.v2); }, uint64: () => { - return { - type: "uint64" - }; + return convertUint64(primitive.v2); }, _other: () => { throw new Error("Unknown primitive: " + primitive.v1); @@ -308,8 +305,11 @@ function convertInteger(primitive: Ir.PrimitiveTypeV2 | undefined): FdrCjsSdk.ap primitive != null && primitive.type === "integer" ? primitive.validation : undefined; return { type: "integer", - minimum: rules != null ? rules.min : undefined, - maximum: rules != null ? rules.max : undefined, + minimum: rules?.exclusiveMin === true ? undefined : rules?.min, + maximum: rules?.exclusiveMax === true ? undefined : rules?.max, + exclusiveMinimum: rules?.exclusiveMin === true ? rules?.min : undefined, + exclusiveMaximum: rules?.exclusiveMax === true ? rules?.max : undefined, + multipleOf: rules?.multipleOf, default: primitive != null && primitive.type === "integer" ? primitive.default : undefined }; } @@ -319,12 +319,57 @@ function convertDouble(primitive: Ir.PrimitiveTypeV2 | undefined): FdrCjsSdk.api primitive != null && primitive.type === "double" ? primitive.validation : undefined; return { type: "double", - minimum: rules != null ? rules.min : undefined, - maximum: rules != null ? rules.max : undefined, + minimum: rules?.exclusiveMin === true ? undefined : rules?.min, + maximum: rules?.exclusiveMax === true ? undefined : rules?.max, + exclusiveMinimum: rules?.exclusiveMin === true ? rules?.min : undefined, + exclusiveMaximum: rules?.exclusiveMax === true ? rules?.max : undefined, + multipleOf: rules?.multipleOf, default: primitive != null && primitive.type === "double" ? primitive.default : undefined }; } +function convertLong(primitive: Ir.PrimitiveTypeV2 | undefined): FdrCjsSdk.api.v1.register.PrimitiveType { + const rules: Ir.LongValidationRules | undefined = + primitive != null && primitive.type === "long" ? primitive.validation : undefined; + return { + type: "long", + minimum: rules?.exclusiveMin === true ? undefined : rules?.min, + maximum: rules?.exclusiveMax === true ? undefined : rules?.max, + exclusiveMinimum: rules?.exclusiveMin === true ? rules?.min : undefined, + exclusiveMaximum: rules?.exclusiveMax === true ? rules?.max : undefined, + multipleOf: rules?.multipleOf, + default: primitive != null && primitive.type === "long" ? primitive.default : undefined + }; +} + +function convertUint(primitive: Ir.PrimitiveTypeV2 | undefined): FdrCjsSdk.api.v1.register.PrimitiveType { + const rules: Ir.UintValidationRules | undefined = + primitive != null && primitive.type === "uint" ? primitive.validation : undefined; + return { + type: "uint", + minimum: rules?.exclusiveMin === true ? undefined : rules?.min, + maximum: rules?.exclusiveMax === true ? undefined : rules?.max, + exclusiveMinimum: rules?.exclusiveMin === true ? rules?.min : undefined, + exclusiveMaximum: rules?.exclusiveMax === true ? rules?.max : undefined, + multipleOf: rules?.multipleOf, + default: primitive != null && primitive.type === "uint" ? primitive.default : undefined + }; +} + +function convertUint64(primitive: Ir.PrimitiveTypeV2 | undefined): FdrCjsSdk.api.v1.register.PrimitiveType { + const rules: Ir.Uint64ValidationRules | undefined = + primitive != null && primitive.type === "uint64" ? primitive.validation : undefined; + return { + type: "uint64", + minimum: rules?.exclusiveMin === true ? undefined : rules?.min, + maximum: rules?.exclusiveMax === true ? undefined : rules?.max, + exclusiveMinimum: rules?.exclusiveMin === true ? rules?.min : undefined, + exclusiveMaximum: rules?.exclusiveMax === true ? rules?.max : undefined, + multipleOf: rules?.multipleOf, + default: primitive != null && primitive.type === "uint64" ? primitive.default : undefined + }; +} + function convertExtraProperties( extraProperties: boolean | string | Ir.types.TypeReference ): FdrCjsSdk.api.v1.register.TypeReference | undefined { diff --git a/packages/cli/workspace/lazy-fern-workspace/src/index.ts b/packages/cli/workspace/lazy-fern-workspace/src/index.ts index 0991956250bc..0d68dd7c060e 100644 --- a/packages/cli/workspace/lazy-fern-workspace/src/index.ts +++ b/packages/cli/workspace/lazy-fern-workspace/src/index.ts @@ -2,4 +2,5 @@ export * from "./ConjureWorkspace"; export * from "./LazyFernWorkspace"; export { OpenAPILoader } from "./loaders/OpenAPILoader"; export * from "./OSSWorkspace"; +export { detectAirGappedMode, isNetworkError } from "./protobuf/utils"; export * from "./utils"; diff --git a/packages/cli/workspace/lazy-fern-workspace/src/protobuf/utils.ts b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/utils.ts index 5a49242fd75e..6e99d277fff8 100644 --- a/packages/cli/workspace/lazy-fern-workspace/src/protobuf/utils.ts +++ b/packages/cli/workspace/lazy-fern-workspace/src/protobuf/utils.ts @@ -6,24 +6,75 @@ import tmp from "tmp-promise"; /** * Check if an error message indicates a network error. + * Used by both protobuf generation and AI example enhancement for air-gap detection. */ export function isNetworkError(errorMessage: string): boolean { return ( errorMessage.includes("server hosted at that remote is unavailable") || + errorMessage.includes("fetch failed") || errorMessage.includes("failed to connect") || errorMessage.includes("network") || errorMessage.includes("ENOTFOUND") || errorMessage.includes("ETIMEDOUT") || errorMessage.includes("TIMEDOUT") || - errorMessage.includes("timed out") + errorMessage.includes("timed out") || + errorMessage.includes("ECONNREFUSED") || + errorMessage.includes("ECONNRESET") || + errorMessage.includes("socket hang up") ); } +// Global cache for air-gap detection result +let airGapDetectionResult: boolean | undefined; +let airGapDetectionPromise: Promise | undefined; + +/** + * Detect if we're in an air-gapped environment by trying to reach a URL via HTTP. + * The result is cached globally to avoid repeated detection attempts. + * Used by both protobuf generation and AI example enhancement. + */ +export async function detectAirGappedMode(url: string, logger: Logger, timeoutMs: number = 5000): Promise { + if (airGapDetectionResult !== undefined) { + return airGapDetectionResult; + } + + if (airGapDetectionPromise == null) { + airGapDetectionPromise = performAirGapDetection(url, logger, timeoutMs); + } + + return airGapDetectionPromise; +} + +async function performAirGapDetection(url: string, logger: Logger, timeoutMs: number): Promise { + logger.debug(`Detecting air-gapped mode by checking connectivity to ${url}`); + + try { + await fetch(url, { + method: "GET", + signal: AbortSignal.timeout(timeoutMs) + }); + + airGapDetectionResult = false; + logger.debug("Network check succeeded - not in air-gapped mode"); + return false; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + if (isNetworkError(errorMessage)) { + airGapDetectionResult = true; + logger.debug(`Network check failed - entering air-gapped mode: ${errorMessage}`); + return true; + } + airGapDetectionResult = false; + return false; + } +} + /** - * Detect if we're in an air-gapped environment by trying buf dep update once. + * Detect if we're in an air-gapped environment for protobuf generation. + * Uses buf dep update to check network availability. * Returns true if air-gapped (network unavailable), false otherwise. */ -export async function detectAirGappedMode( +export async function detectAirGappedModeForProtobuf( absoluteFilepathToProtobufRoot: AbsoluteFilePath, logger: Logger ): Promise { diff --git a/packages/cli/workspace/loader/src/docs-yml.schema.json b/packages/cli/workspace/loader/src/docs-yml.schema.json index 1ef92a792dda..4b671d96bf44 100644 --- a/packages/cli/workspace/loader/src/docs-yml.schema.json +++ b/packages/cli/workspace/loader/src/docs-yml.schema.json @@ -3788,6 +3788,16 @@ "type": "null" } ] + }, + "exclude-apis": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] } }, "additionalProperties": false diff --git a/packages/cli/yaml/docs-validator/package.json b/packages/cli/yaml/docs-validator/package.json index 6ee97309a3c7..0e051f2fe6aa 100644 --- a/packages/cli/yaml/docs-validator/package.json +++ b/packages/cli/yaml/docs-validator/package.json @@ -37,7 +37,7 @@ "@fern-api/core-utils": "workspace:*", "@fern-api/docs-markdown-utils": "workspace:*", "@fern-api/docs-resolver": "workspace:*", - "@fern-api/fdr-sdk": "0.142.22-c021dd849", + "@fern-api/fdr-sdk": "0.143.6-48348b476", "@fern-api/fern-definition-schema": "workspace:*", "@fern-api/fs-utils": "workspace:*", "@fern-api/ir-generator": "workspace:*", diff --git a/packages/cli/yaml/docs-validator/src/rules/valid-local-references/valid-local-references.ts b/packages/cli/yaml/docs-validator/src/rules/valid-local-references/valid-local-references.ts index 5beaa21a1aeb..cf9179e46ac9 100644 --- a/packages/cli/yaml/docs-validator/src/rules/valid-local-references/valid-local-references.ts +++ b/packages/cli/yaml/docs-validator/src/rules/valid-local-references/valid-local-references.ts @@ -147,7 +147,7 @@ export const ValidLocalReferencesRule: Rule = { const errorMessage = createInformativeErrorMessage(invalidRefs); violations.push({ - severity: "error", + severity: "warning", name: "Invalid OpenAPI References", message: errorMessage, relativeFilepath: relativePath diff --git a/packages/core/package.json b/packages/core/package.json index 5b613296d3b5..5b92a6c025f4 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -32,7 +32,7 @@ "test:update": "vitest --passWithNoTests --run -u" }, "dependencies": { - "@fern-api/fdr-sdk": "0.142.22-c021dd849", + "@fern-api/fdr-sdk": "0.143.6-48348b476", "@fern-api/venus-api-sdk": "0.17.3-3-gf696595", "@fern-fern/fdr-test-sdk": "^0.0.5297", "@fern-fern/fiddle-sdk": "0.0.738", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 48e22b69cca4..94a2100a32a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,7 +16,7 @@ overrides: node-fetch@2.x>whatwg-url: ^14.0.0 qs: 6.14.1 url-join: ^4.0.1 - '@fern-api/fdr-sdk': 0.142.24-9b6f8eefe + '@fern-api/fdr-sdk': 0.143.6-48348b476 es-toolkit: ^1.39.10 ts-essentials: ^10.1.1 form-data: ^4.0.4 @@ -4490,6 +4490,9 @@ importers: '@fern-api/cli-source-resolver': specifier: workspace:* version: link:../cli-source-resolver + '@fern-api/cli-v2': + specifier: workspace:* + version: link:../cli-v2 '@fern-api/configs': specifier: workspace:* version: link:../../configs @@ -4658,9 +4661,6 @@ importers: '@types/yargs': specifier: ^17.0.28 version: 17.0.35 - ansi-escapes: - specifier: ^5.0.0 - version: 5.0.0 axios: specifier: ^1.12.0 version: 1.12.2 @@ -4697,9 +4697,6 @@ importers: openapi-types: specifier: ^12.1.3 version: 12.1.3 - ora: - specifier: ^7.0.1 - version: 7.0.1 semver: specifier: ^7.6.2 version: 7.7.3 @@ -4743,12 +4740,33 @@ importers: '@fern-api/configs': specifier: workspace:* version: link:../../configs + '@fern-api/core-utils': + specifier: workspace:* + version: link:../../commons/core-utils + '@fern-api/logger': + specifier: workspace:* + version: link:../logger + '@fern-api/task-context': + specifier: workspace:* + version: link:../task-context + '@types/is-ci': + specifier: ^3.0.4 + version: 3.0.4 '@types/node': specifier: 18.15.3 version: 18.15.3 + ansi-escapes: + specifier: ^5.0.0 + version: 5.0.0 depcheck: specifier: ^1.4.7 version: 1.4.7 + is-ci: + specifier: ^3.0.1 + version: 3.0.1 + ora: + specifier: ^7.0.1 + version: 7.0.1 typescript: specifier: 5.9.3 version: 5.9.3 @@ -4860,14 +4878,44 @@ importers: specifier: ^4.0.8 version: 4.0.8(@types/debug@4.1.12)(@types/node@18.15.3)(jsdom@27.1.0)(msw@2.12.1(@types/node@18.15.3)(typescript@5.9.3))(sass@1.94.0)(terser@5.44.1)(tsx@4.20.6)(yaml@2.3.3) + packages/cli/cli-v2: + devDependencies: + '@fern-api/cli-logger': + specifier: workspace:* + version: link:../cli-logger + '@fern-api/configs': + specifier: workspace:* + version: link:../../configs + '@fern-api/logger': + specifier: workspace:* + version: link:../logger + '@types/node': + specifier: 18.15.3 + version: 18.15.3 + '@types/yargs': + specifier: ^17.0.28 + version: 17.0.35 + depcheck: + specifier: ^1.4.7 + version: 1.4.7 + typescript: + specifier: 5.9.3 + version: 5.9.3 + vitest: + specifier: ^4.0.8 + version: 4.0.8(@types/debug@4.1.12)(@types/node@18.15.3)(jsdom@27.1.0)(msw@2.12.1(@types/node@18.15.3)(typescript@5.9.3))(sass@1.94.0)(terser@5.44.1)(tsx@4.20.6)(yaml@2.3.3) + yargs: + specifier: ^17.4.1 + version: 17.7.2 + packages/cli/configuration: dependencies: '@fern-api/core-utils': specifier: workspace:* version: link:../../commons/core-utils '@fern-api/fdr-sdk': - specifier: 0.142.24-9b6f8eefe - version: 0.142.24-9b6f8eefe(typescript@5.9.3) + specifier: 0.143.6-48348b476 + version: 0.143.6-48348b476(typescript@5.9.3) '@fern-api/fern-definition-schema': specifier: workspace:* version: link:../fern-definition/schema @@ -4924,8 +4972,8 @@ importers: specifier: workspace:* version: link:../../commons/core-utils '@fern-api/fdr-sdk': - specifier: 0.142.24-9b6f8eefe - version: 0.142.24-9b6f8eefe(typescript@5.9.3) + specifier: 0.143.6-48348b476 + version: 0.143.6-48348b476(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../commons/fs-utils @@ -5003,8 +5051,8 @@ importers: specifier: workspace:* version: link:../../configuration '@fern-api/fdr-sdk': - specifier: 0.142.24-9b6f8eefe - version: 0.142.24-9b6f8eefe(typescript@5.9.3) + specifier: 0.143.6-48348b476 + version: 0.143.6-48348b476(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../../commons/fs-utils @@ -5046,8 +5094,8 @@ importers: specifier: workspace:* version: link:../commons '@fern-api/fdr-sdk': - specifier: 0.142.24-9b6f8eefe - version: 0.142.24-9b6f8eefe(typescript@5.9.3) + specifier: 0.143.6-48348b476 + version: 0.143.6-48348b476(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../../commons/fs-utils @@ -5089,8 +5137,8 @@ importers: specifier: workspace:* version: link:../commons '@fern-api/fdr-sdk': - specifier: 0.142.24-9b6f8eefe - version: 0.142.24-9b6f8eefe(typescript@5.9.3) + specifier: 0.143.6-48348b476 + version: 0.143.6-48348b476(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../../commons/fs-utils @@ -5159,8 +5207,8 @@ importers: packages/cli/docs-markdown-utils: dependencies: '@fern-api/fdr-sdk': - specifier: 0.142.24-9b6f8eefe - version: 0.142.24-9b6f8eefe(typescript@5.9.3) + specifier: 0.143.6-48348b476 + version: 0.143.6-48348b476(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../commons/fs-utils @@ -5247,8 +5295,8 @@ importers: specifier: workspace:* version: link:../docs-resolver '@fern-api/fdr-sdk': - specifier: 0.142.24-9b6f8eefe - version: 0.142.24-9b6f8eefe(typescript@5.9.3) + specifier: 0.143.6-48348b476 + version: 0.143.6-48348b476(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../commons/fs-utils @@ -5365,8 +5413,8 @@ importers: specifier: 0.0.65 version: 0.0.65(typescript@5.9.3) '@fern-api/fdr-sdk': - specifier: 0.142.24-9b6f8eefe - version: 0.142.24-9b6f8eefe(typescript@5.9.3) + specifier: 0.143.6-48348b476 + version: 0.143.6-48348b476(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../commons/fs-utils @@ -5450,8 +5498,8 @@ importers: specifier: workspace:* version: link:../configuration '@fern-api/fdr-sdk': - specifier: 0.142.24-9b6f8eefe - version: 0.142.24-9b6f8eefe(typescript@5.9.3) + specifier: 0.143.6-48348b476 + version: 0.143.6-48348b476(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../commons/fs-utils @@ -6302,8 +6350,8 @@ importers: specifier: 0.0.6-2ee1b7e28 version: 0.0.6-2ee1b7e28 '@fern-api/fdr-sdk': - specifier: 0.142.24-9b6f8eefe - version: 0.142.24-9b6f8eefe(typescript@5.9.3) + specifier: 0.143.6-48348b476 + version: 0.143.6-48348b476(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../../../commons/fs-utils @@ -6730,8 +6778,8 @@ importers: specifier: workspace:* version: link:../../commons/core-utils '@fern-api/fdr-sdk': - specifier: 0.142.24-9b6f8eefe - version: 0.142.24-9b6f8eefe(typescript@5.9.3) + specifier: 0.143.6-48348b476 + version: 0.143.6-48348b476(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../commons/fs-utils @@ -7215,8 +7263,8 @@ importers: specifier: workspace:* version: link:../../docs-resolver '@fern-api/fdr-sdk': - specifier: 0.142.24-9b6f8eefe - version: 0.142.24-9b6f8eefe(typescript@5.9.3) + specifier: 0.143.6-48348b476 + version: 0.143.6-48348b476(typescript@5.9.3) '@fern-api/fern-definition-schema': specifier: workspace:* version: link:../../fern-definition/schema @@ -7765,8 +7813,8 @@ importers: packages/core: dependencies: '@fern-api/fdr-sdk': - specifier: 0.142.24-9b6f8eefe - version: 0.142.24-9b6f8eefe(typescript@5.9.3) + specifier: 0.143.6-48348b476 + version: 0.143.6-48348b476(typescript@5.9.3) '@fern-api/venus-api-sdk': specifier: 0.17.3-3-gf696595 version: 0.17.3-3-gf696595 @@ -9411,8 +9459,8 @@ packages: resolution: {integrity: sha512-3qhAAuc4ZJWLaFtyZzaYXfF9OQ5iviNrvLDXtjKScKUNS134fR3v3c3xedCidTq5KedapuBECziUaOmmd6KXVA==} engines: {node: '>=18.0.0'} - '@fern-api/fdr-sdk@0.142.24-9b6f8eefe': - resolution: {integrity: sha512-fuJjQUhWELyV61bZQxLg2vkzA+B+jSueKDzA1iBVenBXcavdlQQlDlkhOV4wWDnz2KwUg0Rk09le6m7ZvhtgfA==} + '@fern-api/fdr-sdk@0.143.6-48348b476': + resolution: {integrity: sha512-QMRc04M6H3fPKn6/u/qm5R0dAuPezs9T6nqLk+L24V7mSfM+GPRzHeeUnWcXdYfcTC0ZUyX2lYZBFd+t7v/nFA==} '@fern-api/generator-cli@0.3.0': resolution: {integrity: sha512-9FCrYy7j+SPDWNc82YIM6H6rlvUI6Zebzy3rqQj+JNz07cig6292X8AS58ydkM4Kj3NWEPHFW9qNo2KU/C9EHQ==} @@ -9427,8 +9475,8 @@ packages: '@fern-api/ui-core-utils@0.129.4-b6c699ad2': resolution: {integrity: sha512-V1jfV4u5fhpWEoLqCIA1QtRGpRR0NXyk68VGEHmEsezwA/gNF4587MJp5FWN59YsZmRt2hozODnp/umJ/iwkPg==} - '@fern-api/ui-core-utils@0.142.24-9b6f8eefe': - resolution: {integrity: sha512-AJuWtkLCVYZUt3879MbBGgaoP/Znxs5qneebRn4QsGzVehGeeilwAh/YdYDh8zR71Z65Z4tA7RdVfYv+7HR69A==} + '@fern-api/ui-core-utils@0.143.6-48348b476': + resolution: {integrity: sha512-elZ0FQKbFg3rvXrpocSZjlACUDxJYZ3JKi+fyuE1MqsEgYozvJwqcZMOVIUASX638j8/5O2E1XGpOlbFFCdzFA==} '@fern-api/venus-api-sdk@0.17.3-3-gf696595': resolution: {integrity: sha512-JwtEDPtlrayTOTc4NR9opF4bD1LQtz222h1UnKseAv6Saopg2iHvxoGSf0SQfHA8i8atw+eJMNbHBWWTPdbBrA==} @@ -17389,9 +17437,9 @@ snapshots: '@fern-api/fai-sdk@0.0.6-2ee1b7e28': {} - '@fern-api/fdr-sdk@0.142.24-9b6f8eefe(typescript@5.9.3)': + '@fern-api/fdr-sdk@0.143.6-48348b476(typescript@5.9.3)': dependencies: - '@fern-api/ui-core-utils': 0.142.24-9b6f8eefe + '@fern-api/ui-core-utils': 0.143.6-48348b476 '@types/readable-stream': 4.0.22 '@ungap/structured-clone': 1.3.0 dayjs: 1.11.19 @@ -17435,7 +17483,7 @@ snapshots: title: 3.5.3 ua-parser-js: 1.0.41 - '@fern-api/ui-core-utils@0.142.24-9b6f8eefe': + '@fern-api/ui-core-utils@0.143.6-48348b476': dependencies: date-fns: 4.1.0 date-fns-tz: 3.2.0(date-fns@4.1.0) diff --git a/seed/csharp-sdk/accept-header/src/SeedAccept.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/accept-header/src/SeedAccept.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/accept-header/src/SeedAccept.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/accept-header/src/SeedAccept.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/accept-header/src/SeedAccept.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/accept-header/src/SeedAccept.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..9bd4d70ebf3f --- /dev/null +++ b/seed/csharp-sdk/accept-header/src/SeedAccept.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedAccept.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/accept-header/src/SeedAccept/Core/JsonConfiguration.cs b/seed/csharp-sdk/accept-header/src/SeedAccept/Core/JsonConfiguration.cs index 74e1869c0315..086d3267a8eb 100644 --- a/seed/csharp-sdk/accept-header/src/SeedAccept/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/accept-header/src/SeedAccept/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/accept-header/src/SeedAccept/Core/NullableAttribute.cs b/seed/csharp-sdk/accept-header/src/SeedAccept/Core/NullableAttribute.cs new file mode 100644 index 000000000000..b9778dda9a61 --- /dev/null +++ b/seed/csharp-sdk/accept-header/src/SeedAccept/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedAccept.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/accept-header/src/SeedAccept/Core/Optional.cs b/seed/csharp-sdk/accept-header/src/SeedAccept/Core/Optional.cs new file mode 100644 index 000000000000..0d89990c5e01 --- /dev/null +++ b/seed/csharp-sdk/accept-header/src/SeedAccept/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedAccept.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/accept-header/src/SeedAccept/Core/OptionalAttribute.cs b/seed/csharp-sdk/accept-header/src/SeedAccept/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..b21e839aa464 --- /dev/null +++ b/seed/csharp-sdk/accept-header/src/SeedAccept/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedAccept.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/accept-header/src/SeedAccept/Core/RawClient.cs b/seed/csharp-sdk/accept-header/src/SeedAccept/Core/RawClient.cs index 6303d185b660..39bdf82f732e 100644 --- a/seed/csharp-sdk/accept-header/src/SeedAccept/Core/RawClient.cs +++ b/seed/csharp-sdk/accept-header/src/SeedAccept/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/alias-extends/src/SeedAliasExtends.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/alias-extends/src/SeedAliasExtends.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/alias-extends/src/SeedAliasExtends.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/alias-extends/src/SeedAliasExtends.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/alias-extends/src/SeedAliasExtends.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/alias-extends/src/SeedAliasExtends.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..9ca26de6e842 --- /dev/null +++ b/seed/csharp-sdk/alias-extends/src/SeedAliasExtends.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedAliasExtends.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/alias-extends/src/SeedAliasExtends/Core/JsonConfiguration.cs b/seed/csharp-sdk/alias-extends/src/SeedAliasExtends/Core/JsonConfiguration.cs index 7df759a3e910..9ad76a3d31f1 100644 --- a/seed/csharp-sdk/alias-extends/src/SeedAliasExtends/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/alias-extends/src/SeedAliasExtends/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/alias-extends/src/SeedAliasExtends/Core/NullableAttribute.cs b/seed/csharp-sdk/alias-extends/src/SeedAliasExtends/Core/NullableAttribute.cs new file mode 100644 index 000000000000..c8541f1c07dd --- /dev/null +++ b/seed/csharp-sdk/alias-extends/src/SeedAliasExtends/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedAliasExtends.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/alias-extends/src/SeedAliasExtends/Core/Optional.cs b/seed/csharp-sdk/alias-extends/src/SeedAliasExtends/Core/Optional.cs new file mode 100644 index 000000000000..bc4baa34e7ae --- /dev/null +++ b/seed/csharp-sdk/alias-extends/src/SeedAliasExtends/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedAliasExtends.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/alias-extends/src/SeedAliasExtends/Core/OptionalAttribute.cs b/seed/csharp-sdk/alias-extends/src/SeedAliasExtends/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..ba20d637df29 --- /dev/null +++ b/seed/csharp-sdk/alias-extends/src/SeedAliasExtends/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedAliasExtends.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/alias-extends/src/SeedAliasExtends/Core/RawClient.cs b/seed/csharp-sdk/alias-extends/src/SeedAliasExtends/Core/RawClient.cs index c0f3167dee32..f0c94a7bdf4f 100644 --- a/seed/csharp-sdk/alias-extends/src/SeedAliasExtends/Core/RawClient.cs +++ b/seed/csharp-sdk/alias-extends/src/SeedAliasExtends/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/alias/src/SeedAlias.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/alias/src/SeedAlias.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/alias/src/SeedAlias.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/alias/src/SeedAlias.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/alias/src/SeedAlias.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/alias/src/SeedAlias.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..e29484dd19f3 --- /dev/null +++ b/seed/csharp-sdk/alias/src/SeedAlias.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedAlias.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/alias/src/SeedAlias/Core/JsonConfiguration.cs b/seed/csharp-sdk/alias/src/SeedAlias/Core/JsonConfiguration.cs index a0933ad3854c..2adbd5f1746a 100644 --- a/seed/csharp-sdk/alias/src/SeedAlias/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/alias/src/SeedAlias/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/alias/src/SeedAlias/Core/NullableAttribute.cs b/seed/csharp-sdk/alias/src/SeedAlias/Core/NullableAttribute.cs new file mode 100644 index 000000000000..bbf6619c5552 --- /dev/null +++ b/seed/csharp-sdk/alias/src/SeedAlias/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedAlias.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/alias/src/SeedAlias/Core/Optional.cs b/seed/csharp-sdk/alias/src/SeedAlias/Core/Optional.cs new file mode 100644 index 000000000000..d8554b3b3a5c --- /dev/null +++ b/seed/csharp-sdk/alias/src/SeedAlias/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedAlias.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/alias/src/SeedAlias/Core/OptionalAttribute.cs b/seed/csharp-sdk/alias/src/SeedAlias/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..89d8b70666f8 --- /dev/null +++ b/seed/csharp-sdk/alias/src/SeedAlias/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedAlias.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/alias/src/SeedAlias/Core/RawClient.cs b/seed/csharp-sdk/alias/src/SeedAlias/Core/RawClient.cs index 15e82f95ac89..25517f9ca7e9 100644 --- a/seed/csharp-sdk/alias/src/SeedAlias/Core/RawClient.cs +++ b/seed/csharp-sdk/alias/src/SeedAlias/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/any-auth/src/SeedAnyAuth.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/any-auth/src/SeedAnyAuth.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/any-auth/src/SeedAnyAuth.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/any-auth/src/SeedAnyAuth.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/any-auth/src/SeedAnyAuth.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/any-auth/src/SeedAnyAuth.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..68452fd502b6 --- /dev/null +++ b/seed/csharp-sdk/any-auth/src/SeedAnyAuth.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedAnyAuth.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/any-auth/src/SeedAnyAuth/Core/JsonConfiguration.cs b/seed/csharp-sdk/any-auth/src/SeedAnyAuth/Core/JsonConfiguration.cs index 3a5331c48cc3..c01f3e401c3a 100644 --- a/seed/csharp-sdk/any-auth/src/SeedAnyAuth/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/any-auth/src/SeedAnyAuth/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/any-auth/src/SeedAnyAuth/Core/NullableAttribute.cs b/seed/csharp-sdk/any-auth/src/SeedAnyAuth/Core/NullableAttribute.cs new file mode 100644 index 000000000000..5e2f48a9d8a6 --- /dev/null +++ b/seed/csharp-sdk/any-auth/src/SeedAnyAuth/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedAnyAuth.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/any-auth/src/SeedAnyAuth/Core/Optional.cs b/seed/csharp-sdk/any-auth/src/SeedAnyAuth/Core/Optional.cs new file mode 100644 index 000000000000..768d6a62cd87 --- /dev/null +++ b/seed/csharp-sdk/any-auth/src/SeedAnyAuth/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedAnyAuth.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/any-auth/src/SeedAnyAuth/Core/OptionalAttribute.cs b/seed/csharp-sdk/any-auth/src/SeedAnyAuth/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..af240012572f --- /dev/null +++ b/seed/csharp-sdk/any-auth/src/SeedAnyAuth/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedAnyAuth.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/any-auth/src/SeedAnyAuth/Core/RawClient.cs b/seed/csharp-sdk/any-auth/src/SeedAnyAuth/Core/RawClient.cs index 334faefcf1e0..cb57b7e399ee 100644 --- a/seed/csharp-sdk/any-auth/src/SeedAnyAuth/Core/RawClient.cs +++ b/seed/csharp-sdk/any-auth/src/SeedAnyAuth/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/api-wide-base-path/src/SeedApiWideBasePath.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/api-wide-base-path/src/SeedApiWideBasePath.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/api-wide-base-path/src/SeedApiWideBasePath.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/api-wide-base-path/src/SeedApiWideBasePath.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/api-wide-base-path/src/SeedApiWideBasePath.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/api-wide-base-path/src/SeedApiWideBasePath.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..f9068e1b1c97 --- /dev/null +++ b/seed/csharp-sdk/api-wide-base-path/src/SeedApiWideBasePath.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedApiWideBasePath.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/api-wide-base-path/src/SeedApiWideBasePath/Core/JsonConfiguration.cs b/seed/csharp-sdk/api-wide-base-path/src/SeedApiWideBasePath/Core/JsonConfiguration.cs index ce45c903440c..2fe2a8899618 100644 --- a/seed/csharp-sdk/api-wide-base-path/src/SeedApiWideBasePath/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/api-wide-base-path/src/SeedApiWideBasePath/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/api-wide-base-path/src/SeedApiWideBasePath/Core/NullableAttribute.cs b/seed/csharp-sdk/api-wide-base-path/src/SeedApiWideBasePath/Core/NullableAttribute.cs new file mode 100644 index 000000000000..14502fd9baf7 --- /dev/null +++ b/seed/csharp-sdk/api-wide-base-path/src/SeedApiWideBasePath/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedApiWideBasePath.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/api-wide-base-path/src/SeedApiWideBasePath/Core/Optional.cs b/seed/csharp-sdk/api-wide-base-path/src/SeedApiWideBasePath/Core/Optional.cs new file mode 100644 index 000000000000..a4b2dcaf3cd5 --- /dev/null +++ b/seed/csharp-sdk/api-wide-base-path/src/SeedApiWideBasePath/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedApiWideBasePath.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/api-wide-base-path/src/SeedApiWideBasePath/Core/OptionalAttribute.cs b/seed/csharp-sdk/api-wide-base-path/src/SeedApiWideBasePath/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..ece04d4a304b --- /dev/null +++ b/seed/csharp-sdk/api-wide-base-path/src/SeedApiWideBasePath/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedApiWideBasePath.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/api-wide-base-path/src/SeedApiWideBasePath/Core/RawClient.cs b/seed/csharp-sdk/api-wide-base-path/src/SeedApiWideBasePath/Core/RawClient.cs index de1ee959fd28..46556279351f 100644 --- a/seed/csharp-sdk/api-wide-base-path/src/SeedApiWideBasePath/Core/RawClient.cs +++ b/seed/csharp-sdk/api-wide-base-path/src/SeedApiWideBasePath/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/audiences/src/SeedAudiences.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/audiences/src/SeedAudiences.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/audiences/src/SeedAudiences.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/audiences/src/SeedAudiences.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/audiences/src/SeedAudiences.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/audiences/src/SeedAudiences.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..f77b0833c8dc --- /dev/null +++ b/seed/csharp-sdk/audiences/src/SeedAudiences.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedAudiences.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/audiences/src/SeedAudiences/Core/JsonConfiguration.cs b/seed/csharp-sdk/audiences/src/SeedAudiences/Core/JsonConfiguration.cs index fcb4fe4fd50b..fbc38487f18c 100644 --- a/seed/csharp-sdk/audiences/src/SeedAudiences/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/audiences/src/SeedAudiences/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/audiences/src/SeedAudiences/Core/NullableAttribute.cs b/seed/csharp-sdk/audiences/src/SeedAudiences/Core/NullableAttribute.cs new file mode 100644 index 000000000000..f5f8077a3c2b --- /dev/null +++ b/seed/csharp-sdk/audiences/src/SeedAudiences/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedAudiences.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/audiences/src/SeedAudiences/Core/Optional.cs b/seed/csharp-sdk/audiences/src/SeedAudiences/Core/Optional.cs new file mode 100644 index 000000000000..00dddca8ee09 --- /dev/null +++ b/seed/csharp-sdk/audiences/src/SeedAudiences/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedAudiences.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/audiences/src/SeedAudiences/Core/OptionalAttribute.cs b/seed/csharp-sdk/audiences/src/SeedAudiences/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..0851863d3829 --- /dev/null +++ b/seed/csharp-sdk/audiences/src/SeedAudiences/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedAudiences.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/audiences/src/SeedAudiences/Core/RawClient.cs b/seed/csharp-sdk/audiences/src/SeedAudiences/Core/RawClient.cs index c2eabfac2190..683428b5089e 100644 --- a/seed/csharp-sdk/audiences/src/SeedAudiences/Core/RawClient.cs +++ b/seed/csharp-sdk/audiences/src/SeedAudiences/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/basic-auth-environment-variables/src/SeedBasicAuthEnvironmentVariables.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/basic-auth-environment-variables/src/SeedBasicAuthEnvironmentVariables.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/basic-auth-environment-variables/src/SeedBasicAuthEnvironmentVariables.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/basic-auth-environment-variables/src/SeedBasicAuthEnvironmentVariables.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/basic-auth-environment-variables/src/SeedBasicAuthEnvironmentVariables.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/basic-auth-environment-variables/src/SeedBasicAuthEnvironmentVariables.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..3ad4c35614e0 --- /dev/null +++ b/seed/csharp-sdk/basic-auth-environment-variables/src/SeedBasicAuthEnvironmentVariables.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedBasicAuthEnvironmentVariables.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/basic-auth-environment-variables/src/SeedBasicAuthEnvironmentVariables/Core/JsonConfiguration.cs b/seed/csharp-sdk/basic-auth-environment-variables/src/SeedBasicAuthEnvironmentVariables/Core/JsonConfiguration.cs index 62ab08d5b35e..97695e7c4159 100644 --- a/seed/csharp-sdk/basic-auth-environment-variables/src/SeedBasicAuthEnvironmentVariables/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/basic-auth-environment-variables/src/SeedBasicAuthEnvironmentVariables/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/basic-auth-environment-variables/src/SeedBasicAuthEnvironmentVariables/Core/NullableAttribute.cs b/seed/csharp-sdk/basic-auth-environment-variables/src/SeedBasicAuthEnvironmentVariables/Core/NullableAttribute.cs new file mode 100644 index 000000000000..9a2f553a83cb --- /dev/null +++ b/seed/csharp-sdk/basic-auth-environment-variables/src/SeedBasicAuthEnvironmentVariables/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedBasicAuthEnvironmentVariables.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/basic-auth-environment-variables/src/SeedBasicAuthEnvironmentVariables/Core/Optional.cs b/seed/csharp-sdk/basic-auth-environment-variables/src/SeedBasicAuthEnvironmentVariables/Core/Optional.cs new file mode 100644 index 000000000000..f4b3fdd6da6a --- /dev/null +++ b/seed/csharp-sdk/basic-auth-environment-variables/src/SeedBasicAuthEnvironmentVariables/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedBasicAuthEnvironmentVariables.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/basic-auth-environment-variables/src/SeedBasicAuthEnvironmentVariables/Core/OptionalAttribute.cs b/seed/csharp-sdk/basic-auth-environment-variables/src/SeedBasicAuthEnvironmentVariables/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..82ba3ce303d6 --- /dev/null +++ b/seed/csharp-sdk/basic-auth-environment-variables/src/SeedBasicAuthEnvironmentVariables/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedBasicAuthEnvironmentVariables.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/basic-auth-environment-variables/src/SeedBasicAuthEnvironmentVariables/Core/RawClient.cs b/seed/csharp-sdk/basic-auth-environment-variables/src/SeedBasicAuthEnvironmentVariables/Core/RawClient.cs index cd137db94c0f..d36514494ef7 100644 --- a/seed/csharp-sdk/basic-auth-environment-variables/src/SeedBasicAuthEnvironmentVariables/Core/RawClient.cs +++ b/seed/csharp-sdk/basic-auth-environment-variables/src/SeedBasicAuthEnvironmentVariables/Core/RawClient.cs @@ -357,6 +357,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/basic-auth/src/SeedBasicAuth.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/basic-auth/src/SeedBasicAuth.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/basic-auth/src/SeedBasicAuth.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/basic-auth/src/SeedBasicAuth.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/basic-auth/src/SeedBasicAuth.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/basic-auth/src/SeedBasicAuth.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..8961650a89a8 --- /dev/null +++ b/seed/csharp-sdk/basic-auth/src/SeedBasicAuth.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedBasicAuth.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/basic-auth/src/SeedBasicAuth/Core/JsonConfiguration.cs b/seed/csharp-sdk/basic-auth/src/SeedBasicAuth/Core/JsonConfiguration.cs index b3ee4757508f..b211e3b6c000 100644 --- a/seed/csharp-sdk/basic-auth/src/SeedBasicAuth/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/basic-auth/src/SeedBasicAuth/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/basic-auth/src/SeedBasicAuth/Core/NullableAttribute.cs b/seed/csharp-sdk/basic-auth/src/SeedBasicAuth/Core/NullableAttribute.cs new file mode 100644 index 000000000000..3fa2a0fcacbb --- /dev/null +++ b/seed/csharp-sdk/basic-auth/src/SeedBasicAuth/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedBasicAuth.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/basic-auth/src/SeedBasicAuth/Core/Optional.cs b/seed/csharp-sdk/basic-auth/src/SeedBasicAuth/Core/Optional.cs new file mode 100644 index 000000000000..aa2843b801c9 --- /dev/null +++ b/seed/csharp-sdk/basic-auth/src/SeedBasicAuth/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedBasicAuth.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/basic-auth/src/SeedBasicAuth/Core/OptionalAttribute.cs b/seed/csharp-sdk/basic-auth/src/SeedBasicAuth/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..db2476eed04a --- /dev/null +++ b/seed/csharp-sdk/basic-auth/src/SeedBasicAuth/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedBasicAuth.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/basic-auth/src/SeedBasicAuth/Core/RawClient.cs b/seed/csharp-sdk/basic-auth/src/SeedBasicAuth/Core/RawClient.cs index f8d3dc4b780c..57eb453f571d 100644 --- a/seed/csharp-sdk/basic-auth/src/SeedBasicAuth/Core/RawClient.cs +++ b/seed/csharp-sdk/basic-auth/src/SeedBasicAuth/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/bearer-token-environment-variable/src/SeedBearerTokenEnvironmentVariable.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/bearer-token-environment-variable/src/SeedBearerTokenEnvironmentVariable.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/bearer-token-environment-variable/src/SeedBearerTokenEnvironmentVariable.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/bearer-token-environment-variable/src/SeedBearerTokenEnvironmentVariable.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/bearer-token-environment-variable/src/SeedBearerTokenEnvironmentVariable.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/bearer-token-environment-variable/src/SeedBearerTokenEnvironmentVariable.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..1dd4cd982928 --- /dev/null +++ b/seed/csharp-sdk/bearer-token-environment-variable/src/SeedBearerTokenEnvironmentVariable.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedBearerTokenEnvironmentVariable.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/bearer-token-environment-variable/src/SeedBearerTokenEnvironmentVariable/Core/JsonConfiguration.cs b/seed/csharp-sdk/bearer-token-environment-variable/src/SeedBearerTokenEnvironmentVariable/Core/JsonConfiguration.cs index 961670414705..12fdb9992255 100644 --- a/seed/csharp-sdk/bearer-token-environment-variable/src/SeedBearerTokenEnvironmentVariable/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/bearer-token-environment-variable/src/SeedBearerTokenEnvironmentVariable/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/bearer-token-environment-variable/src/SeedBearerTokenEnvironmentVariable/Core/NullableAttribute.cs b/seed/csharp-sdk/bearer-token-environment-variable/src/SeedBearerTokenEnvironmentVariable/Core/NullableAttribute.cs new file mode 100644 index 000000000000..7677692e24c2 --- /dev/null +++ b/seed/csharp-sdk/bearer-token-environment-variable/src/SeedBearerTokenEnvironmentVariable/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedBearerTokenEnvironmentVariable.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/bearer-token-environment-variable/src/SeedBearerTokenEnvironmentVariable/Core/Optional.cs b/seed/csharp-sdk/bearer-token-environment-variable/src/SeedBearerTokenEnvironmentVariable/Core/Optional.cs new file mode 100644 index 000000000000..b0704782cd79 --- /dev/null +++ b/seed/csharp-sdk/bearer-token-environment-variable/src/SeedBearerTokenEnvironmentVariable/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedBearerTokenEnvironmentVariable.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/bearer-token-environment-variable/src/SeedBearerTokenEnvironmentVariable/Core/OptionalAttribute.cs b/seed/csharp-sdk/bearer-token-environment-variable/src/SeedBearerTokenEnvironmentVariable/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..1a3d5ca0ac3c --- /dev/null +++ b/seed/csharp-sdk/bearer-token-environment-variable/src/SeedBearerTokenEnvironmentVariable/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedBearerTokenEnvironmentVariable.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/bearer-token-environment-variable/src/SeedBearerTokenEnvironmentVariable/Core/RawClient.cs b/seed/csharp-sdk/bearer-token-environment-variable/src/SeedBearerTokenEnvironmentVariable/Core/RawClient.cs index 4096cc162744..ed1501ece6fb 100644 --- a/seed/csharp-sdk/bearer-token-environment-variable/src/SeedBearerTokenEnvironmentVariable/Core/RawClient.cs +++ b/seed/csharp-sdk/bearer-token-environment-variable/src/SeedBearerTokenEnvironmentVariable/Core/RawClient.cs @@ -357,6 +357,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/bytes-download/src/SeedBytesDownload.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/bytes-download/src/SeedBytesDownload.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/bytes-download/src/SeedBytesDownload.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/bytes-download/src/SeedBytesDownload.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/bytes-download/src/SeedBytesDownload.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/bytes-download/src/SeedBytesDownload.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..71b98cf871b6 --- /dev/null +++ b/seed/csharp-sdk/bytes-download/src/SeedBytesDownload.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedBytesDownload.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/bytes-download/src/SeedBytesDownload/Core/JsonConfiguration.cs b/seed/csharp-sdk/bytes-download/src/SeedBytesDownload/Core/JsonConfiguration.cs index 428bbe296cca..87b102ba0c68 100644 --- a/seed/csharp-sdk/bytes-download/src/SeedBytesDownload/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/bytes-download/src/SeedBytesDownload/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/bytes-download/src/SeedBytesDownload/Core/NullableAttribute.cs b/seed/csharp-sdk/bytes-download/src/SeedBytesDownload/Core/NullableAttribute.cs new file mode 100644 index 000000000000..2972a69ee727 --- /dev/null +++ b/seed/csharp-sdk/bytes-download/src/SeedBytesDownload/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedBytesDownload.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/bytes-download/src/SeedBytesDownload/Core/Optional.cs b/seed/csharp-sdk/bytes-download/src/SeedBytesDownload/Core/Optional.cs new file mode 100644 index 000000000000..f08bc50e850e --- /dev/null +++ b/seed/csharp-sdk/bytes-download/src/SeedBytesDownload/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedBytesDownload.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/bytes-download/src/SeedBytesDownload/Core/OptionalAttribute.cs b/seed/csharp-sdk/bytes-download/src/SeedBytesDownload/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..3405137e8433 --- /dev/null +++ b/seed/csharp-sdk/bytes-download/src/SeedBytesDownload/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedBytesDownload.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/bytes-download/src/SeedBytesDownload/Core/RawClient.cs b/seed/csharp-sdk/bytes-download/src/SeedBytesDownload/Core/RawClient.cs index 95805be65e69..0d99dba41b67 100644 --- a/seed/csharp-sdk/bytes-download/src/SeedBytesDownload/Core/RawClient.cs +++ b/seed/csharp-sdk/bytes-download/src/SeedBytesDownload/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/bytes-upload/src/SeedBytesUpload.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/bytes-upload/src/SeedBytesUpload.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/bytes-upload/src/SeedBytesUpload.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/bytes-upload/src/SeedBytesUpload.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/bytes-upload/src/SeedBytesUpload.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/bytes-upload/src/SeedBytesUpload.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..4988d5727cb8 --- /dev/null +++ b/seed/csharp-sdk/bytes-upload/src/SeedBytesUpload.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedBytesUpload.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/bytes-upload/src/SeedBytesUpload/Core/JsonConfiguration.cs b/seed/csharp-sdk/bytes-upload/src/SeedBytesUpload/Core/JsonConfiguration.cs index ae89b3bb5421..35d771efdca6 100644 --- a/seed/csharp-sdk/bytes-upload/src/SeedBytesUpload/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/bytes-upload/src/SeedBytesUpload/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/bytes-upload/src/SeedBytesUpload/Core/NullableAttribute.cs b/seed/csharp-sdk/bytes-upload/src/SeedBytesUpload/Core/NullableAttribute.cs new file mode 100644 index 000000000000..217b75435053 --- /dev/null +++ b/seed/csharp-sdk/bytes-upload/src/SeedBytesUpload/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedBytesUpload.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/bytes-upload/src/SeedBytesUpload/Core/Optional.cs b/seed/csharp-sdk/bytes-upload/src/SeedBytesUpload/Core/Optional.cs new file mode 100644 index 000000000000..01dd2851a01c --- /dev/null +++ b/seed/csharp-sdk/bytes-upload/src/SeedBytesUpload/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedBytesUpload.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/bytes-upload/src/SeedBytesUpload/Core/OptionalAttribute.cs b/seed/csharp-sdk/bytes-upload/src/SeedBytesUpload/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..371a82984ac1 --- /dev/null +++ b/seed/csharp-sdk/bytes-upload/src/SeedBytesUpload/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedBytesUpload.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/bytes-upload/src/SeedBytesUpload/Core/RawClient.cs b/seed/csharp-sdk/bytes-upload/src/SeedBytesUpload/Core/RawClient.cs index a729ed506998..33d4f60a1085 100644 --- a/seed/csharp-sdk/bytes-upload/src/SeedBytesUpload/Core/RawClient.cs +++ b/seed/csharp-sdk/bytes-upload/src/SeedBytesUpload/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/circular-references-advanced/src/SeedApi.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/circular-references-advanced/src/SeedApi.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/circular-references-advanced/src/SeedApi.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/circular-references-advanced/src/SeedApi.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/circular-references-advanced/src/SeedApi.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/circular-references-advanced/src/SeedApi.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..dc732084c15f --- /dev/null +++ b/seed/csharp-sdk/circular-references-advanced/src/SeedApi.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedApi.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/circular-references-advanced/src/SeedApi/Core/JsonConfiguration.cs b/seed/csharp-sdk/circular-references-advanced/src/SeedApi/Core/JsonConfiguration.cs index 321193691456..df6b2c46945d 100644 --- a/seed/csharp-sdk/circular-references-advanced/src/SeedApi/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/circular-references-advanced/src/SeedApi/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/circular-references-advanced/src/SeedApi/Core/NullableAttribute.cs b/seed/csharp-sdk/circular-references-advanced/src/SeedApi/Core/NullableAttribute.cs new file mode 100644 index 000000000000..8fca30535a2a --- /dev/null +++ b/seed/csharp-sdk/circular-references-advanced/src/SeedApi/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedApi.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/circular-references-advanced/src/SeedApi/Core/Optional.cs b/seed/csharp-sdk/circular-references-advanced/src/SeedApi/Core/Optional.cs new file mode 100644 index 000000000000..4a9691feca1f --- /dev/null +++ b/seed/csharp-sdk/circular-references-advanced/src/SeedApi/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedApi.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/circular-references-advanced/src/SeedApi/Core/OptionalAttribute.cs b/seed/csharp-sdk/circular-references-advanced/src/SeedApi/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..b2bc05f7244f --- /dev/null +++ b/seed/csharp-sdk/circular-references-advanced/src/SeedApi/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedApi.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/circular-references-advanced/src/SeedApi/Core/RawClient.cs b/seed/csharp-sdk/circular-references-advanced/src/SeedApi/Core/RawClient.cs index 72e34bbc0e89..b60968e4eb03 100644 --- a/seed/csharp-sdk/circular-references-advanced/src/SeedApi/Core/RawClient.cs +++ b/seed/csharp-sdk/circular-references-advanced/src/SeedApi/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/circular-references/src/SeedApi.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/circular-references/src/SeedApi.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/circular-references/src/SeedApi.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/circular-references/src/SeedApi.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/circular-references/src/SeedApi.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/circular-references/src/SeedApi.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..dc732084c15f --- /dev/null +++ b/seed/csharp-sdk/circular-references/src/SeedApi.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedApi.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/circular-references/src/SeedApi/Core/JsonConfiguration.cs b/seed/csharp-sdk/circular-references/src/SeedApi/Core/JsonConfiguration.cs index 321193691456..df6b2c46945d 100644 --- a/seed/csharp-sdk/circular-references/src/SeedApi/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/circular-references/src/SeedApi/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/circular-references/src/SeedApi/Core/NullableAttribute.cs b/seed/csharp-sdk/circular-references/src/SeedApi/Core/NullableAttribute.cs new file mode 100644 index 000000000000..8fca30535a2a --- /dev/null +++ b/seed/csharp-sdk/circular-references/src/SeedApi/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedApi.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/circular-references/src/SeedApi/Core/Optional.cs b/seed/csharp-sdk/circular-references/src/SeedApi/Core/Optional.cs new file mode 100644 index 000000000000..4a9691feca1f --- /dev/null +++ b/seed/csharp-sdk/circular-references/src/SeedApi/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedApi.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/circular-references/src/SeedApi/Core/OptionalAttribute.cs b/seed/csharp-sdk/circular-references/src/SeedApi/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..b2bc05f7244f --- /dev/null +++ b/seed/csharp-sdk/circular-references/src/SeedApi/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedApi.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/circular-references/src/SeedApi/Core/RawClient.cs b/seed/csharp-sdk/circular-references/src/SeedApi/Core/RawClient.cs index 72e34bbc0e89..b60968e4eb03 100644 --- a/seed/csharp-sdk/circular-references/src/SeedApi/Core/RawClient.cs +++ b/seed/csharp-sdk/circular-references/src/SeedApi/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/client-side-params/src/SeedClientSideParams.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/client-side-params/src/SeedClientSideParams.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/client-side-params/src/SeedClientSideParams.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/client-side-params/src/SeedClientSideParams.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/client-side-params/src/SeedClientSideParams.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/client-side-params/src/SeedClientSideParams.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..f25ad3421cdf --- /dev/null +++ b/seed/csharp-sdk/client-side-params/src/SeedClientSideParams.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedClientSideParams.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/client-side-params/src/SeedClientSideParams/Core/JsonConfiguration.cs b/seed/csharp-sdk/client-side-params/src/SeedClientSideParams/Core/JsonConfiguration.cs index 14cf31b9ad16..ea668c528586 100644 --- a/seed/csharp-sdk/client-side-params/src/SeedClientSideParams/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/client-side-params/src/SeedClientSideParams/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/client-side-params/src/SeedClientSideParams/Core/NullableAttribute.cs b/seed/csharp-sdk/client-side-params/src/SeedClientSideParams/Core/NullableAttribute.cs new file mode 100644 index 000000000000..878d44383f98 --- /dev/null +++ b/seed/csharp-sdk/client-side-params/src/SeedClientSideParams/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedClientSideParams.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/client-side-params/src/SeedClientSideParams/Core/Optional.cs b/seed/csharp-sdk/client-side-params/src/SeedClientSideParams/Core/Optional.cs new file mode 100644 index 000000000000..405f4a437b8a --- /dev/null +++ b/seed/csharp-sdk/client-side-params/src/SeedClientSideParams/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedClientSideParams.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/client-side-params/src/SeedClientSideParams/Core/OptionalAttribute.cs b/seed/csharp-sdk/client-side-params/src/SeedClientSideParams/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..9136cbc93a69 --- /dev/null +++ b/seed/csharp-sdk/client-side-params/src/SeedClientSideParams/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedClientSideParams.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/client-side-params/src/SeedClientSideParams/Core/RawClient.cs b/seed/csharp-sdk/client-side-params/src/SeedClientSideParams/Core/RawClient.cs index ca488371480b..3224fd21589a 100644 --- a/seed/csharp-sdk/client-side-params/src/SeedClientSideParams/Core/RawClient.cs +++ b/seed/csharp-sdk/client-side-params/src/SeedClientSideParams/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/content-type/src/SeedContentTypes.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/content-type/src/SeedContentTypes.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/content-type/src/SeedContentTypes.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/content-type/src/SeedContentTypes.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/content-type/src/SeedContentTypes.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/content-type/src/SeedContentTypes.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..e2a9cfb1881f --- /dev/null +++ b/seed/csharp-sdk/content-type/src/SeedContentTypes.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedContentTypes.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/content-type/src/SeedContentTypes/Core/JsonConfiguration.cs b/seed/csharp-sdk/content-type/src/SeedContentTypes/Core/JsonConfiguration.cs index 26d99240ce7f..a4be9205bb70 100644 --- a/seed/csharp-sdk/content-type/src/SeedContentTypes/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/content-type/src/SeedContentTypes/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/content-type/src/SeedContentTypes/Core/NullableAttribute.cs b/seed/csharp-sdk/content-type/src/SeedContentTypes/Core/NullableAttribute.cs new file mode 100644 index 000000000000..4056c744d67f --- /dev/null +++ b/seed/csharp-sdk/content-type/src/SeedContentTypes/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedContentTypes.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/content-type/src/SeedContentTypes/Core/Optional.cs b/seed/csharp-sdk/content-type/src/SeedContentTypes/Core/Optional.cs new file mode 100644 index 000000000000..446da3ab1635 --- /dev/null +++ b/seed/csharp-sdk/content-type/src/SeedContentTypes/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedContentTypes.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/content-type/src/SeedContentTypes/Core/OptionalAttribute.cs b/seed/csharp-sdk/content-type/src/SeedContentTypes/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..95a099604ac1 --- /dev/null +++ b/seed/csharp-sdk/content-type/src/SeedContentTypes/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedContentTypes.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/content-type/src/SeedContentTypes/Core/RawClient.cs b/seed/csharp-sdk/content-type/src/SeedContentTypes/Core/RawClient.cs index 8203685782c9..d6a6a2299a9b 100644 --- a/seed/csharp-sdk/content-type/src/SeedContentTypes/Core/RawClient.cs +++ b/seed/csharp-sdk/content-type/src/SeedContentTypes/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/cross-package-type-names/src/SeedCrossPackageTypeNames.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/cross-package-type-names/src/SeedCrossPackageTypeNames.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/cross-package-type-names/src/SeedCrossPackageTypeNames.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/cross-package-type-names/src/SeedCrossPackageTypeNames.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/cross-package-type-names/src/SeedCrossPackageTypeNames.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/cross-package-type-names/src/SeedCrossPackageTypeNames.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..6f853af23f8e --- /dev/null +++ b/seed/csharp-sdk/cross-package-type-names/src/SeedCrossPackageTypeNames.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedCrossPackageTypeNames.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/cross-package-type-names/src/SeedCrossPackageTypeNames/Core/JsonConfiguration.cs b/seed/csharp-sdk/cross-package-type-names/src/SeedCrossPackageTypeNames/Core/JsonConfiguration.cs index 7bec7725b213..0804f28a7407 100644 --- a/seed/csharp-sdk/cross-package-type-names/src/SeedCrossPackageTypeNames/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/cross-package-type-names/src/SeedCrossPackageTypeNames/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/cross-package-type-names/src/SeedCrossPackageTypeNames/Core/NullableAttribute.cs b/seed/csharp-sdk/cross-package-type-names/src/SeedCrossPackageTypeNames/Core/NullableAttribute.cs new file mode 100644 index 000000000000..04d6c7b2c593 --- /dev/null +++ b/seed/csharp-sdk/cross-package-type-names/src/SeedCrossPackageTypeNames/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedCrossPackageTypeNames.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/cross-package-type-names/src/SeedCrossPackageTypeNames/Core/Optional.cs b/seed/csharp-sdk/cross-package-type-names/src/SeedCrossPackageTypeNames/Core/Optional.cs new file mode 100644 index 000000000000..0ba3fc6c5ace --- /dev/null +++ b/seed/csharp-sdk/cross-package-type-names/src/SeedCrossPackageTypeNames/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedCrossPackageTypeNames.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/cross-package-type-names/src/SeedCrossPackageTypeNames/Core/OptionalAttribute.cs b/seed/csharp-sdk/cross-package-type-names/src/SeedCrossPackageTypeNames/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..4d1dfc5309bc --- /dev/null +++ b/seed/csharp-sdk/cross-package-type-names/src/SeedCrossPackageTypeNames/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedCrossPackageTypeNames.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/cross-package-type-names/src/SeedCrossPackageTypeNames/Core/RawClient.cs b/seed/csharp-sdk/cross-package-type-names/src/SeedCrossPackageTypeNames/Core/RawClient.cs index a17025be17e8..dd5236c108e5 100644 --- a/seed/csharp-sdk/cross-package-type-names/src/SeedCrossPackageTypeNames/Core/RawClient.cs +++ b/seed/csharp-sdk/cross-package-type-names/src/SeedCrossPackageTypeNames/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/csharp-grpc-proto-exhaustive/include-exception-handler/src/SeedApi.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/csharp-grpc-proto-exhaustive/include-exception-handler/src/SeedApi.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/csharp-grpc-proto-exhaustive/include-exception-handler/src/SeedApi.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/csharp-grpc-proto-exhaustive/include-exception-handler/src/SeedApi.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/csharp-grpc-proto-exhaustive/include-exception-handler/src/SeedApi.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/csharp-grpc-proto-exhaustive/include-exception-handler/src/SeedApi.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..dc732084c15f --- /dev/null +++ b/seed/csharp-sdk/csharp-grpc-proto-exhaustive/include-exception-handler/src/SeedApi.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedApi.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/csharp-grpc-proto-exhaustive/include-exception-handler/src/SeedApi/Core/JsonConfiguration.cs b/seed/csharp-sdk/csharp-grpc-proto-exhaustive/include-exception-handler/src/SeedApi/Core/JsonConfiguration.cs index 321193691456..df6b2c46945d 100644 --- a/seed/csharp-sdk/csharp-grpc-proto-exhaustive/include-exception-handler/src/SeedApi/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/csharp-grpc-proto-exhaustive/include-exception-handler/src/SeedApi/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/csharp-grpc-proto-exhaustive/include-exception-handler/src/SeedApi/Core/NullableAttribute.cs b/seed/csharp-sdk/csharp-grpc-proto-exhaustive/include-exception-handler/src/SeedApi/Core/NullableAttribute.cs new file mode 100644 index 000000000000..8fca30535a2a --- /dev/null +++ b/seed/csharp-sdk/csharp-grpc-proto-exhaustive/include-exception-handler/src/SeedApi/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedApi.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/csharp-grpc-proto-exhaustive/include-exception-handler/src/SeedApi/Core/Optional.cs b/seed/csharp-sdk/csharp-grpc-proto-exhaustive/include-exception-handler/src/SeedApi/Core/Optional.cs new file mode 100644 index 000000000000..4a9691feca1f --- /dev/null +++ b/seed/csharp-sdk/csharp-grpc-proto-exhaustive/include-exception-handler/src/SeedApi/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedApi.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/csharp-grpc-proto-exhaustive/include-exception-handler/src/SeedApi/Core/OptionalAttribute.cs b/seed/csharp-sdk/csharp-grpc-proto-exhaustive/include-exception-handler/src/SeedApi/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..b2bc05f7244f --- /dev/null +++ b/seed/csharp-sdk/csharp-grpc-proto-exhaustive/include-exception-handler/src/SeedApi/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedApi.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/csharp-grpc-proto-exhaustive/include-exception-handler/src/SeedApi/Core/RawClient.cs b/seed/csharp-sdk/csharp-grpc-proto-exhaustive/include-exception-handler/src/SeedApi/Core/RawClient.cs index 72e34bbc0e89..b60968e4eb03 100644 --- a/seed/csharp-sdk/csharp-grpc-proto-exhaustive/include-exception-handler/src/SeedApi/Core/RawClient.cs +++ b/seed/csharp-sdk/csharp-grpc-proto-exhaustive/include-exception-handler/src/SeedApi/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/csharp-grpc-proto-exhaustive/no-custom-config/src/SeedApi.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/csharp-grpc-proto-exhaustive/no-custom-config/src/SeedApi.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/csharp-grpc-proto-exhaustive/no-custom-config/src/SeedApi.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/csharp-grpc-proto-exhaustive/no-custom-config/src/SeedApi.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/csharp-grpc-proto-exhaustive/no-custom-config/src/SeedApi.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/csharp-grpc-proto-exhaustive/no-custom-config/src/SeedApi.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..dc732084c15f --- /dev/null +++ b/seed/csharp-sdk/csharp-grpc-proto-exhaustive/no-custom-config/src/SeedApi.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedApi.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/csharp-grpc-proto-exhaustive/no-custom-config/src/SeedApi/Core/JsonConfiguration.cs b/seed/csharp-sdk/csharp-grpc-proto-exhaustive/no-custom-config/src/SeedApi/Core/JsonConfiguration.cs index 321193691456..df6b2c46945d 100644 --- a/seed/csharp-sdk/csharp-grpc-proto-exhaustive/no-custom-config/src/SeedApi/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/csharp-grpc-proto-exhaustive/no-custom-config/src/SeedApi/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/csharp-grpc-proto-exhaustive/no-custom-config/src/SeedApi/Core/NullableAttribute.cs b/seed/csharp-sdk/csharp-grpc-proto-exhaustive/no-custom-config/src/SeedApi/Core/NullableAttribute.cs new file mode 100644 index 000000000000..8fca30535a2a --- /dev/null +++ b/seed/csharp-sdk/csharp-grpc-proto-exhaustive/no-custom-config/src/SeedApi/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedApi.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/csharp-grpc-proto-exhaustive/no-custom-config/src/SeedApi/Core/Optional.cs b/seed/csharp-sdk/csharp-grpc-proto-exhaustive/no-custom-config/src/SeedApi/Core/Optional.cs new file mode 100644 index 000000000000..4a9691feca1f --- /dev/null +++ b/seed/csharp-sdk/csharp-grpc-proto-exhaustive/no-custom-config/src/SeedApi/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedApi.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/csharp-grpc-proto-exhaustive/no-custom-config/src/SeedApi/Core/OptionalAttribute.cs b/seed/csharp-sdk/csharp-grpc-proto-exhaustive/no-custom-config/src/SeedApi/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..b2bc05f7244f --- /dev/null +++ b/seed/csharp-sdk/csharp-grpc-proto-exhaustive/no-custom-config/src/SeedApi/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedApi.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/csharp-grpc-proto-exhaustive/no-custom-config/src/SeedApi/Core/RawClient.cs b/seed/csharp-sdk/csharp-grpc-proto-exhaustive/no-custom-config/src/SeedApi/Core/RawClient.cs index 72e34bbc0e89..b60968e4eb03 100644 --- a/seed/csharp-sdk/csharp-grpc-proto-exhaustive/no-custom-config/src/SeedApi/Core/RawClient.cs +++ b/seed/csharp-sdk/csharp-grpc-proto-exhaustive/no-custom-config/src/SeedApi/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/csharp-grpc-proto-exhaustive/package-id/src/SeedApi.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/csharp-grpc-proto-exhaustive/package-id/src/SeedApi.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/csharp-grpc-proto-exhaustive/package-id/src/SeedApi.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/csharp-grpc-proto-exhaustive/package-id/src/SeedApi.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/csharp-grpc-proto-exhaustive/package-id/src/SeedApi.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/csharp-grpc-proto-exhaustive/package-id/src/SeedApi.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..dc732084c15f --- /dev/null +++ b/seed/csharp-sdk/csharp-grpc-proto-exhaustive/package-id/src/SeedApi.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedApi.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/csharp-grpc-proto-exhaustive/package-id/src/SeedApi/Core/JsonConfiguration.cs b/seed/csharp-sdk/csharp-grpc-proto-exhaustive/package-id/src/SeedApi/Core/JsonConfiguration.cs index 321193691456..df6b2c46945d 100644 --- a/seed/csharp-sdk/csharp-grpc-proto-exhaustive/package-id/src/SeedApi/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/csharp-grpc-proto-exhaustive/package-id/src/SeedApi/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/csharp-grpc-proto-exhaustive/package-id/src/SeedApi/Core/NullableAttribute.cs b/seed/csharp-sdk/csharp-grpc-proto-exhaustive/package-id/src/SeedApi/Core/NullableAttribute.cs new file mode 100644 index 000000000000..8fca30535a2a --- /dev/null +++ b/seed/csharp-sdk/csharp-grpc-proto-exhaustive/package-id/src/SeedApi/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedApi.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/csharp-grpc-proto-exhaustive/package-id/src/SeedApi/Core/Optional.cs b/seed/csharp-sdk/csharp-grpc-proto-exhaustive/package-id/src/SeedApi/Core/Optional.cs new file mode 100644 index 000000000000..4a9691feca1f --- /dev/null +++ b/seed/csharp-sdk/csharp-grpc-proto-exhaustive/package-id/src/SeedApi/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedApi.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/csharp-grpc-proto-exhaustive/package-id/src/SeedApi/Core/OptionalAttribute.cs b/seed/csharp-sdk/csharp-grpc-proto-exhaustive/package-id/src/SeedApi/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..b2bc05f7244f --- /dev/null +++ b/seed/csharp-sdk/csharp-grpc-proto-exhaustive/package-id/src/SeedApi/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedApi.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/csharp-grpc-proto-exhaustive/package-id/src/SeedApi/Core/RawClient.cs b/seed/csharp-sdk/csharp-grpc-proto-exhaustive/package-id/src/SeedApi/Core/RawClient.cs index 72e34bbc0e89..b60968e4eb03 100644 --- a/seed/csharp-sdk/csharp-grpc-proto-exhaustive/package-id/src/SeedApi/Core/RawClient.cs +++ b/seed/csharp-sdk/csharp-grpc-proto-exhaustive/package-id/src/SeedApi/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/csharp-grpc-proto-exhaustive/read-only-memory/src/SeedApi.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/csharp-grpc-proto-exhaustive/read-only-memory/src/SeedApi.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/csharp-grpc-proto-exhaustive/read-only-memory/src/SeedApi.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/csharp-grpc-proto-exhaustive/read-only-memory/src/SeedApi.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/csharp-grpc-proto-exhaustive/read-only-memory/src/SeedApi.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/csharp-grpc-proto-exhaustive/read-only-memory/src/SeedApi.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..dc732084c15f --- /dev/null +++ b/seed/csharp-sdk/csharp-grpc-proto-exhaustive/read-only-memory/src/SeedApi.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedApi.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/csharp-grpc-proto-exhaustive/read-only-memory/src/SeedApi/Core/JsonConfiguration.cs b/seed/csharp-sdk/csharp-grpc-proto-exhaustive/read-only-memory/src/SeedApi/Core/JsonConfiguration.cs index 321193691456..df6b2c46945d 100644 --- a/seed/csharp-sdk/csharp-grpc-proto-exhaustive/read-only-memory/src/SeedApi/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/csharp-grpc-proto-exhaustive/read-only-memory/src/SeedApi/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/csharp-grpc-proto-exhaustive/read-only-memory/src/SeedApi/Core/NullableAttribute.cs b/seed/csharp-sdk/csharp-grpc-proto-exhaustive/read-only-memory/src/SeedApi/Core/NullableAttribute.cs new file mode 100644 index 000000000000..8fca30535a2a --- /dev/null +++ b/seed/csharp-sdk/csharp-grpc-proto-exhaustive/read-only-memory/src/SeedApi/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedApi.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/csharp-grpc-proto-exhaustive/read-only-memory/src/SeedApi/Core/Optional.cs b/seed/csharp-sdk/csharp-grpc-proto-exhaustive/read-only-memory/src/SeedApi/Core/Optional.cs new file mode 100644 index 000000000000..4a9691feca1f --- /dev/null +++ b/seed/csharp-sdk/csharp-grpc-proto-exhaustive/read-only-memory/src/SeedApi/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedApi.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/csharp-grpc-proto-exhaustive/read-only-memory/src/SeedApi/Core/OptionalAttribute.cs b/seed/csharp-sdk/csharp-grpc-proto-exhaustive/read-only-memory/src/SeedApi/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..b2bc05f7244f --- /dev/null +++ b/seed/csharp-sdk/csharp-grpc-proto-exhaustive/read-only-memory/src/SeedApi/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedApi.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/csharp-grpc-proto-exhaustive/read-only-memory/src/SeedApi/Core/RawClient.cs b/seed/csharp-sdk/csharp-grpc-proto-exhaustive/read-only-memory/src/SeedApi/Core/RawClient.cs index 72e34bbc0e89..b60968e4eb03 100644 --- a/seed/csharp-sdk/csharp-grpc-proto-exhaustive/read-only-memory/src/SeedApi/Core/RawClient.cs +++ b/seed/csharp-sdk/csharp-grpc-proto-exhaustive/read-only-memory/src/SeedApi/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/csharp-grpc-proto/src/SeedApi.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/csharp-grpc-proto/src/SeedApi.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/csharp-grpc-proto/src/SeedApi.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/csharp-grpc-proto/src/SeedApi.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/csharp-grpc-proto/src/SeedApi.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/csharp-grpc-proto/src/SeedApi.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..dc732084c15f --- /dev/null +++ b/seed/csharp-sdk/csharp-grpc-proto/src/SeedApi.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedApi.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/csharp-grpc-proto/src/SeedApi/Core/JsonConfiguration.cs b/seed/csharp-sdk/csharp-grpc-proto/src/SeedApi/Core/JsonConfiguration.cs index 321193691456..df6b2c46945d 100644 --- a/seed/csharp-sdk/csharp-grpc-proto/src/SeedApi/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/csharp-grpc-proto/src/SeedApi/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/csharp-grpc-proto/src/SeedApi/Core/NullableAttribute.cs b/seed/csharp-sdk/csharp-grpc-proto/src/SeedApi/Core/NullableAttribute.cs new file mode 100644 index 000000000000..8fca30535a2a --- /dev/null +++ b/seed/csharp-sdk/csharp-grpc-proto/src/SeedApi/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedApi.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/csharp-grpc-proto/src/SeedApi/Core/Optional.cs b/seed/csharp-sdk/csharp-grpc-proto/src/SeedApi/Core/Optional.cs new file mode 100644 index 000000000000..4a9691feca1f --- /dev/null +++ b/seed/csharp-sdk/csharp-grpc-proto/src/SeedApi/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedApi.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/csharp-grpc-proto/src/SeedApi/Core/OptionalAttribute.cs b/seed/csharp-sdk/csharp-grpc-proto/src/SeedApi/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..b2bc05f7244f --- /dev/null +++ b/seed/csharp-sdk/csharp-grpc-proto/src/SeedApi/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedApi.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/csharp-grpc-proto/src/SeedApi/Core/RawClient.cs b/seed/csharp-sdk/csharp-grpc-proto/src/SeedApi/Core/RawClient.cs index 72e34bbc0e89..b60968e4eb03 100644 --- a/seed/csharp-sdk/csharp-grpc-proto/src/SeedApi/Core/RawClient.cs +++ b/seed/csharp-sdk/csharp-grpc-proto/src/SeedApi/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/csharp-namespace-collision/fully-qualified-namespaces/src/SeedCsharpNamespaceCollision.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/csharp-namespace-collision/fully-qualified-namespaces/src/SeedCsharpNamespaceCollision.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/csharp-namespace-collision/fully-qualified-namespaces/src/SeedCsharpNamespaceCollision.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/csharp-namespace-collision/fully-qualified-namespaces/src/SeedCsharpNamespaceCollision.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/csharp-namespace-collision/fully-qualified-namespaces/src/SeedCsharpNamespaceCollision.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/csharp-namespace-collision/fully-qualified-namespaces/src/SeedCsharpNamespaceCollision.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..814a97d1f958 --- /dev/null +++ b/seed/csharp-sdk/csharp-namespace-collision/fully-qualified-namespaces/src/SeedCsharpNamespaceCollision.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedCsharpNamespaceCollision.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/csharp-namespace-collision/fully-qualified-namespaces/src/SeedCsharpNamespaceCollision/Core/JsonConfiguration.cs b/seed/csharp-sdk/csharp-namespace-collision/fully-qualified-namespaces/src/SeedCsharpNamespaceCollision/Core/JsonConfiguration.cs index 25761d6dc660..06ef924ca956 100644 --- a/seed/csharp-sdk/csharp-namespace-collision/fully-qualified-namespaces/src/SeedCsharpNamespaceCollision/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/csharp-namespace-collision/fully-qualified-namespaces/src/SeedCsharpNamespaceCollision/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/csharp-namespace-collision/fully-qualified-namespaces/src/SeedCsharpNamespaceCollision/Core/NullableAttribute.cs b/seed/csharp-sdk/csharp-namespace-collision/fully-qualified-namespaces/src/SeedCsharpNamespaceCollision/Core/NullableAttribute.cs new file mode 100644 index 000000000000..d1f4c76b5acc --- /dev/null +++ b/seed/csharp-sdk/csharp-namespace-collision/fully-qualified-namespaces/src/SeedCsharpNamespaceCollision/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedCsharpNamespaceCollision.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/csharp-namespace-collision/fully-qualified-namespaces/src/SeedCsharpNamespaceCollision/Core/Optional.cs b/seed/csharp-sdk/csharp-namespace-collision/fully-qualified-namespaces/src/SeedCsharpNamespaceCollision/Core/Optional.cs new file mode 100644 index 000000000000..0416184f1b11 --- /dev/null +++ b/seed/csharp-sdk/csharp-namespace-collision/fully-qualified-namespaces/src/SeedCsharpNamespaceCollision/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedCsharpNamespaceCollision.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/csharp-namespace-collision/fully-qualified-namespaces/src/SeedCsharpNamespaceCollision/Core/OptionalAttribute.cs b/seed/csharp-sdk/csharp-namespace-collision/fully-qualified-namespaces/src/SeedCsharpNamespaceCollision/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..15f1535789bd --- /dev/null +++ b/seed/csharp-sdk/csharp-namespace-collision/fully-qualified-namespaces/src/SeedCsharpNamespaceCollision/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedCsharpNamespaceCollision.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/csharp-namespace-collision/fully-qualified-namespaces/src/SeedCsharpNamespaceCollision/Core/RawClient.cs b/seed/csharp-sdk/csharp-namespace-collision/fully-qualified-namespaces/src/SeedCsharpNamespaceCollision/Core/RawClient.cs index cfb89ae561a0..8e4e0a3eee3b 100644 --- a/seed/csharp-sdk/csharp-namespace-collision/fully-qualified-namespaces/src/SeedCsharpNamespaceCollision/Core/RawClient.cs +++ b/seed/csharp-sdk/csharp-namespace-collision/fully-qualified-namespaces/src/SeedCsharpNamespaceCollision/Core/RawClient.cs @@ -344,6 +344,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/csharp-namespace-conflict/src/SeedCsharpNamespaceConflict.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/csharp-namespace-conflict/src/SeedCsharpNamespaceConflict.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/csharp-namespace-conflict/src/SeedCsharpNamespaceConflict.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/csharp-namespace-conflict/src/SeedCsharpNamespaceConflict.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/csharp-namespace-conflict/src/SeedCsharpNamespaceConflict.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/csharp-namespace-conflict/src/SeedCsharpNamespaceConflict.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..091f5f4f8dd0 --- /dev/null +++ b/seed/csharp-sdk/csharp-namespace-conflict/src/SeedCsharpNamespaceConflict.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedCsharpNamespaceConflict.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/csharp-namespace-conflict/src/SeedCsharpNamespaceConflict/Core/JsonConfiguration.cs b/seed/csharp-sdk/csharp-namespace-conflict/src/SeedCsharpNamespaceConflict/Core/JsonConfiguration.cs index 13d2d49b0112..d16db35fb68f 100644 --- a/seed/csharp-sdk/csharp-namespace-conflict/src/SeedCsharpNamespaceConflict/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/csharp-namespace-conflict/src/SeedCsharpNamespaceConflict/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/csharp-namespace-conflict/src/SeedCsharpNamespaceConflict/Core/NullableAttribute.cs b/seed/csharp-sdk/csharp-namespace-conflict/src/SeedCsharpNamespaceConflict/Core/NullableAttribute.cs new file mode 100644 index 000000000000..28c4d17cbf27 --- /dev/null +++ b/seed/csharp-sdk/csharp-namespace-conflict/src/SeedCsharpNamespaceConflict/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedCsharpNamespaceConflict.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/csharp-namespace-conflict/src/SeedCsharpNamespaceConflict/Core/Optional.cs b/seed/csharp-sdk/csharp-namespace-conflict/src/SeedCsharpNamespaceConflict/Core/Optional.cs new file mode 100644 index 000000000000..8780cf76ae52 --- /dev/null +++ b/seed/csharp-sdk/csharp-namespace-conflict/src/SeedCsharpNamespaceConflict/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedCsharpNamespaceConflict.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/csharp-namespace-conflict/src/SeedCsharpNamespaceConflict/Core/OptionalAttribute.cs b/seed/csharp-sdk/csharp-namespace-conflict/src/SeedCsharpNamespaceConflict/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..d5da3663094d --- /dev/null +++ b/seed/csharp-sdk/csharp-namespace-conflict/src/SeedCsharpNamespaceConflict/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedCsharpNamespaceConflict.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/csharp-namespace-conflict/src/SeedCsharpNamespaceConflict/Core/RawClient.cs b/seed/csharp-sdk/csharp-namespace-conflict/src/SeedCsharpNamespaceConflict/Core/RawClient.cs index 9d929572401d..9b506d79f9ef 100644 --- a/seed/csharp-sdk/csharp-namespace-conflict/src/SeedCsharpNamespaceConflict/Core/RawClient.cs +++ b/seed/csharp-sdk/csharp-namespace-conflict/src/SeedCsharpNamespaceConflict/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/csharp-system-collision/system-client/src/SeedCsharpSystemCollision.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/csharp-system-collision/system-client/src/SeedCsharpSystemCollision.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/csharp-system-collision/system-client/src/SeedCsharpSystemCollision.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/csharp-system-collision/system-client/src/SeedCsharpSystemCollision.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/csharp-system-collision/system-client/src/SeedCsharpSystemCollision.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/csharp-system-collision/system-client/src/SeedCsharpSystemCollision.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..7823313db442 --- /dev/null +++ b/seed/csharp-sdk/csharp-system-collision/system-client/src/SeedCsharpSystemCollision.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedCsharpSystemCollision.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/csharp-system-collision/system-client/src/SeedCsharpSystemCollision/Core/JsonConfiguration.cs b/seed/csharp-sdk/csharp-system-collision/system-client/src/SeedCsharpSystemCollision/Core/JsonConfiguration.cs index e69aafe0de92..5cf3380065da 100644 --- a/seed/csharp-sdk/csharp-system-collision/system-client/src/SeedCsharpSystemCollision/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/csharp-system-collision/system-client/src/SeedCsharpSystemCollision/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/csharp-system-collision/system-client/src/SeedCsharpSystemCollision/Core/NullableAttribute.cs b/seed/csharp-sdk/csharp-system-collision/system-client/src/SeedCsharpSystemCollision/Core/NullableAttribute.cs new file mode 100644 index 000000000000..b633b9f74c57 --- /dev/null +++ b/seed/csharp-sdk/csharp-system-collision/system-client/src/SeedCsharpSystemCollision/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedCsharpSystemCollision.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/csharp-system-collision/system-client/src/SeedCsharpSystemCollision/Core/Optional.cs b/seed/csharp-sdk/csharp-system-collision/system-client/src/SeedCsharpSystemCollision/Core/Optional.cs new file mode 100644 index 000000000000..1a4f2529fe26 --- /dev/null +++ b/seed/csharp-sdk/csharp-system-collision/system-client/src/SeedCsharpSystemCollision/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedCsharpSystemCollision.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/csharp-system-collision/system-client/src/SeedCsharpSystemCollision/Core/OptionalAttribute.cs b/seed/csharp-sdk/csharp-system-collision/system-client/src/SeedCsharpSystemCollision/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..da1736b24b49 --- /dev/null +++ b/seed/csharp-sdk/csharp-system-collision/system-client/src/SeedCsharpSystemCollision/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedCsharpSystemCollision.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/csharp-system-collision/system-client/src/SeedCsharpSystemCollision/Core/RawClient.cs b/seed/csharp-sdk/csharp-system-collision/system-client/src/SeedCsharpSystemCollision/Core/RawClient.cs index 0c29226a9421..85f9f42485ab 100644 --- a/seed/csharp-sdk/csharp-system-collision/system-client/src/SeedCsharpSystemCollision/Core/RawClient.cs +++ b/seed/csharp-sdk/csharp-system-collision/system-client/src/SeedCsharpSystemCollision/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/csharp-xml-entities/no-custom-config/src/SeedCsharpXmlEntities.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/csharp-xml-entities/no-custom-config/src/SeedCsharpXmlEntities.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/csharp-xml-entities/no-custom-config/src/SeedCsharpXmlEntities.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/csharp-xml-entities/no-custom-config/src/SeedCsharpXmlEntities.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/csharp-xml-entities/no-custom-config/src/SeedCsharpXmlEntities.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/csharp-xml-entities/no-custom-config/src/SeedCsharpXmlEntities.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..304fc3ada2e5 --- /dev/null +++ b/seed/csharp-sdk/csharp-xml-entities/no-custom-config/src/SeedCsharpXmlEntities.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedCsharpXmlEntities.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/csharp-xml-entities/no-custom-config/src/SeedCsharpXmlEntities/Core/JsonConfiguration.cs b/seed/csharp-sdk/csharp-xml-entities/no-custom-config/src/SeedCsharpXmlEntities/Core/JsonConfiguration.cs index 3066ec75ee22..093e225e63f5 100644 --- a/seed/csharp-sdk/csharp-xml-entities/no-custom-config/src/SeedCsharpXmlEntities/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/csharp-xml-entities/no-custom-config/src/SeedCsharpXmlEntities/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/csharp-xml-entities/no-custom-config/src/SeedCsharpXmlEntities/Core/NullableAttribute.cs b/seed/csharp-sdk/csharp-xml-entities/no-custom-config/src/SeedCsharpXmlEntities/Core/NullableAttribute.cs new file mode 100644 index 000000000000..ecbfec967e5b --- /dev/null +++ b/seed/csharp-sdk/csharp-xml-entities/no-custom-config/src/SeedCsharpXmlEntities/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedCsharpXmlEntities.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/csharp-xml-entities/no-custom-config/src/SeedCsharpXmlEntities/Core/Optional.cs b/seed/csharp-sdk/csharp-xml-entities/no-custom-config/src/SeedCsharpXmlEntities/Core/Optional.cs new file mode 100644 index 000000000000..94bcc313f1a4 --- /dev/null +++ b/seed/csharp-sdk/csharp-xml-entities/no-custom-config/src/SeedCsharpXmlEntities/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedCsharpXmlEntities.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/csharp-xml-entities/no-custom-config/src/SeedCsharpXmlEntities/Core/OptionalAttribute.cs b/seed/csharp-sdk/csharp-xml-entities/no-custom-config/src/SeedCsharpXmlEntities/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..b537b5389b44 --- /dev/null +++ b/seed/csharp-sdk/csharp-xml-entities/no-custom-config/src/SeedCsharpXmlEntities/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedCsharpXmlEntities.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/csharp-xml-entities/no-custom-config/src/SeedCsharpXmlEntities/Core/RawClient.cs b/seed/csharp-sdk/csharp-xml-entities/no-custom-config/src/SeedCsharpXmlEntities/Core/RawClient.cs index f78d4bda27a6..0fd3d05ecac7 100644 --- a/seed/csharp-sdk/csharp-xml-entities/no-custom-config/src/SeedCsharpXmlEntities/Core/RawClient.cs +++ b/seed/csharp-sdk/csharp-xml-entities/no-custom-config/src/SeedCsharpXmlEntities/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/dollar-string-examples/src/SeedDollarStringExamples.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/dollar-string-examples/src/SeedDollarStringExamples.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/dollar-string-examples/src/SeedDollarStringExamples.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/dollar-string-examples/src/SeedDollarStringExamples.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/dollar-string-examples/src/SeedDollarStringExamples.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/dollar-string-examples/src/SeedDollarStringExamples.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..dbe3e3167ef5 --- /dev/null +++ b/seed/csharp-sdk/dollar-string-examples/src/SeedDollarStringExamples.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedDollarStringExamples.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/dollar-string-examples/src/SeedDollarStringExamples/Core/JsonConfiguration.cs b/seed/csharp-sdk/dollar-string-examples/src/SeedDollarStringExamples/Core/JsonConfiguration.cs index 987e7c6209d9..2f42c2b9433b 100644 --- a/seed/csharp-sdk/dollar-string-examples/src/SeedDollarStringExamples/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/dollar-string-examples/src/SeedDollarStringExamples/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/dollar-string-examples/src/SeedDollarStringExamples/Core/NullableAttribute.cs b/seed/csharp-sdk/dollar-string-examples/src/SeedDollarStringExamples/Core/NullableAttribute.cs new file mode 100644 index 000000000000..4bc5e829d7ec --- /dev/null +++ b/seed/csharp-sdk/dollar-string-examples/src/SeedDollarStringExamples/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedDollarStringExamples.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/dollar-string-examples/src/SeedDollarStringExamples/Core/Optional.cs b/seed/csharp-sdk/dollar-string-examples/src/SeedDollarStringExamples/Core/Optional.cs new file mode 100644 index 000000000000..83f904c2bbfb --- /dev/null +++ b/seed/csharp-sdk/dollar-string-examples/src/SeedDollarStringExamples/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedDollarStringExamples.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/dollar-string-examples/src/SeedDollarStringExamples/Core/OptionalAttribute.cs b/seed/csharp-sdk/dollar-string-examples/src/SeedDollarStringExamples/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..063e63397a9b --- /dev/null +++ b/seed/csharp-sdk/dollar-string-examples/src/SeedDollarStringExamples/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedDollarStringExamples.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/dollar-string-examples/src/SeedDollarStringExamples/Core/RawClient.cs b/seed/csharp-sdk/dollar-string-examples/src/SeedDollarStringExamples/Core/RawClient.cs index d0b874794b47..01576ab740e6 100644 --- a/seed/csharp-sdk/dollar-string-examples/src/SeedDollarStringExamples/Core/RawClient.cs +++ b/seed/csharp-sdk/dollar-string-examples/src/SeedDollarStringExamples/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/empty-clients/src/SeedEmptyClients.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/empty-clients/src/SeedEmptyClients.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/empty-clients/src/SeedEmptyClients.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/empty-clients/src/SeedEmptyClients.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/empty-clients/src/SeedEmptyClients.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/empty-clients/src/SeedEmptyClients.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..7df81937ac44 --- /dev/null +++ b/seed/csharp-sdk/empty-clients/src/SeedEmptyClients.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedEmptyClients.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/empty-clients/src/SeedEmptyClients/Core/JsonConfiguration.cs b/seed/csharp-sdk/empty-clients/src/SeedEmptyClients/Core/JsonConfiguration.cs index 9b67de107d1e..74a738125e3e 100644 --- a/seed/csharp-sdk/empty-clients/src/SeedEmptyClients/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/empty-clients/src/SeedEmptyClients/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/empty-clients/src/SeedEmptyClients/Core/NullableAttribute.cs b/seed/csharp-sdk/empty-clients/src/SeedEmptyClients/Core/NullableAttribute.cs new file mode 100644 index 000000000000..de92ae44a90a --- /dev/null +++ b/seed/csharp-sdk/empty-clients/src/SeedEmptyClients/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedEmptyClients.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/empty-clients/src/SeedEmptyClients/Core/Optional.cs b/seed/csharp-sdk/empty-clients/src/SeedEmptyClients/Core/Optional.cs new file mode 100644 index 000000000000..98a9d8007e58 --- /dev/null +++ b/seed/csharp-sdk/empty-clients/src/SeedEmptyClients/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedEmptyClients.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/empty-clients/src/SeedEmptyClients/Core/OptionalAttribute.cs b/seed/csharp-sdk/empty-clients/src/SeedEmptyClients/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..e2dfd6d75812 --- /dev/null +++ b/seed/csharp-sdk/empty-clients/src/SeedEmptyClients/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedEmptyClients.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/empty-clients/src/SeedEmptyClients/Core/RawClient.cs b/seed/csharp-sdk/empty-clients/src/SeedEmptyClients/Core/RawClient.cs index e5e7bafce640..d8ea7327ebdf 100644 --- a/seed/csharp-sdk/empty-clients/src/SeedEmptyClients/Core/RawClient.cs +++ b/seed/csharp-sdk/empty-clients/src/SeedEmptyClients/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/endpoint-security-auth/src/SeedEndpointSecurityAuth.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/endpoint-security-auth/src/SeedEndpointSecurityAuth.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/endpoint-security-auth/src/SeedEndpointSecurityAuth.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/endpoint-security-auth/src/SeedEndpointSecurityAuth.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/endpoint-security-auth/src/SeedEndpointSecurityAuth.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/endpoint-security-auth/src/SeedEndpointSecurityAuth.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..2d821aa8eefb --- /dev/null +++ b/seed/csharp-sdk/endpoint-security-auth/src/SeedEndpointSecurityAuth.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedEndpointSecurityAuth.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/endpoint-security-auth/src/SeedEndpointSecurityAuth/Core/JsonConfiguration.cs b/seed/csharp-sdk/endpoint-security-auth/src/SeedEndpointSecurityAuth/Core/JsonConfiguration.cs index 8c0378667c8f..b9d6c3847ea1 100644 --- a/seed/csharp-sdk/endpoint-security-auth/src/SeedEndpointSecurityAuth/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/endpoint-security-auth/src/SeedEndpointSecurityAuth/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/endpoint-security-auth/src/SeedEndpointSecurityAuth/Core/NullableAttribute.cs b/seed/csharp-sdk/endpoint-security-auth/src/SeedEndpointSecurityAuth/Core/NullableAttribute.cs new file mode 100644 index 000000000000..77bba58f122f --- /dev/null +++ b/seed/csharp-sdk/endpoint-security-auth/src/SeedEndpointSecurityAuth/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedEndpointSecurityAuth.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/endpoint-security-auth/src/SeedEndpointSecurityAuth/Core/Optional.cs b/seed/csharp-sdk/endpoint-security-auth/src/SeedEndpointSecurityAuth/Core/Optional.cs new file mode 100644 index 000000000000..01379347ec69 --- /dev/null +++ b/seed/csharp-sdk/endpoint-security-auth/src/SeedEndpointSecurityAuth/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedEndpointSecurityAuth.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/endpoint-security-auth/src/SeedEndpointSecurityAuth/Core/OptionalAttribute.cs b/seed/csharp-sdk/endpoint-security-auth/src/SeedEndpointSecurityAuth/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..80feb5cec3da --- /dev/null +++ b/seed/csharp-sdk/endpoint-security-auth/src/SeedEndpointSecurityAuth/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedEndpointSecurityAuth.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/endpoint-security-auth/src/SeedEndpointSecurityAuth/Core/RawClient.cs b/seed/csharp-sdk/endpoint-security-auth/src/SeedEndpointSecurityAuth/Core/RawClient.cs index 42e3ad14626d..06eb41374e93 100644 --- a/seed/csharp-sdk/endpoint-security-auth/src/SeedEndpointSecurityAuth/Core/RawClient.cs +++ b/seed/csharp-sdk/endpoint-security-auth/src/SeedEndpointSecurityAuth/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/enum/forward-compatible-enums/src/SeedEnum.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/enum/forward-compatible-enums/src/SeedEnum.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/enum/forward-compatible-enums/src/SeedEnum.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/enum/forward-compatible-enums/src/SeedEnum.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/enum/forward-compatible-enums/src/SeedEnum.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/enum/forward-compatible-enums/src/SeedEnum.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..406799cbc447 --- /dev/null +++ b/seed/csharp-sdk/enum/forward-compatible-enums/src/SeedEnum.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedEnum.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/enum/forward-compatible-enums/src/SeedEnum/Core/JsonConfiguration.cs b/seed/csharp-sdk/enum/forward-compatible-enums/src/SeedEnum/Core/JsonConfiguration.cs index d6f837b5168c..46f14dc8d458 100644 --- a/seed/csharp-sdk/enum/forward-compatible-enums/src/SeedEnum/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/enum/forward-compatible-enums/src/SeedEnum/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/enum/forward-compatible-enums/src/SeedEnum/Core/NullableAttribute.cs b/seed/csharp-sdk/enum/forward-compatible-enums/src/SeedEnum/Core/NullableAttribute.cs new file mode 100644 index 000000000000..f8884d035493 --- /dev/null +++ b/seed/csharp-sdk/enum/forward-compatible-enums/src/SeedEnum/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedEnum.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/enum/forward-compatible-enums/src/SeedEnum/Core/Optional.cs b/seed/csharp-sdk/enum/forward-compatible-enums/src/SeedEnum/Core/Optional.cs new file mode 100644 index 000000000000..fd5185dacd81 --- /dev/null +++ b/seed/csharp-sdk/enum/forward-compatible-enums/src/SeedEnum/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedEnum.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/enum/forward-compatible-enums/src/SeedEnum/Core/OptionalAttribute.cs b/seed/csharp-sdk/enum/forward-compatible-enums/src/SeedEnum/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..28c8a52dce57 --- /dev/null +++ b/seed/csharp-sdk/enum/forward-compatible-enums/src/SeedEnum/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedEnum.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/enum/forward-compatible-enums/src/SeedEnum/Core/RawClient.cs b/seed/csharp-sdk/enum/forward-compatible-enums/src/SeedEnum/Core/RawClient.cs index c700ad2cf8a9..04f276924e05 100644 --- a/seed/csharp-sdk/enum/forward-compatible-enums/src/SeedEnum/Core/RawClient.cs +++ b/seed/csharp-sdk/enum/forward-compatible-enums/src/SeedEnum/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/enum/plain-enums/src/SeedEnum.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/enum/plain-enums/src/SeedEnum.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/enum/plain-enums/src/SeedEnum.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/enum/plain-enums/src/SeedEnum.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/enum/plain-enums/src/SeedEnum.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/enum/plain-enums/src/SeedEnum.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..406799cbc447 --- /dev/null +++ b/seed/csharp-sdk/enum/plain-enums/src/SeedEnum.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedEnum.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/enum/plain-enums/src/SeedEnum/Core/JsonConfiguration.cs b/seed/csharp-sdk/enum/plain-enums/src/SeedEnum/Core/JsonConfiguration.cs index d6f837b5168c..46f14dc8d458 100644 --- a/seed/csharp-sdk/enum/plain-enums/src/SeedEnum/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/enum/plain-enums/src/SeedEnum/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/enum/plain-enums/src/SeedEnum/Core/NullableAttribute.cs b/seed/csharp-sdk/enum/plain-enums/src/SeedEnum/Core/NullableAttribute.cs new file mode 100644 index 000000000000..f8884d035493 --- /dev/null +++ b/seed/csharp-sdk/enum/plain-enums/src/SeedEnum/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedEnum.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/enum/plain-enums/src/SeedEnum/Core/Optional.cs b/seed/csharp-sdk/enum/plain-enums/src/SeedEnum/Core/Optional.cs new file mode 100644 index 000000000000..fd5185dacd81 --- /dev/null +++ b/seed/csharp-sdk/enum/plain-enums/src/SeedEnum/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedEnum.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/enum/plain-enums/src/SeedEnum/Core/OptionalAttribute.cs b/seed/csharp-sdk/enum/plain-enums/src/SeedEnum/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..28c8a52dce57 --- /dev/null +++ b/seed/csharp-sdk/enum/plain-enums/src/SeedEnum/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedEnum.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/enum/plain-enums/src/SeedEnum/Core/RawClient.cs b/seed/csharp-sdk/enum/plain-enums/src/SeedEnum/Core/RawClient.cs index c700ad2cf8a9..04f276924e05 100644 --- a/seed/csharp-sdk/enum/plain-enums/src/SeedEnum/Core/RawClient.cs +++ b/seed/csharp-sdk/enum/plain-enums/src/SeedEnum/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/error-property/src/SeedErrorProperty.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/error-property/src/SeedErrorProperty.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/error-property/src/SeedErrorProperty.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/error-property/src/SeedErrorProperty.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/error-property/src/SeedErrorProperty.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/error-property/src/SeedErrorProperty.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..ba9ba7c27f97 --- /dev/null +++ b/seed/csharp-sdk/error-property/src/SeedErrorProperty.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedErrorProperty.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/error-property/src/SeedErrorProperty/Core/JsonConfiguration.cs b/seed/csharp-sdk/error-property/src/SeedErrorProperty/Core/JsonConfiguration.cs index 44442806277c..988007f978e7 100644 --- a/seed/csharp-sdk/error-property/src/SeedErrorProperty/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/error-property/src/SeedErrorProperty/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/error-property/src/SeedErrorProperty/Core/NullableAttribute.cs b/seed/csharp-sdk/error-property/src/SeedErrorProperty/Core/NullableAttribute.cs new file mode 100644 index 000000000000..b8841e9ceb0f --- /dev/null +++ b/seed/csharp-sdk/error-property/src/SeedErrorProperty/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedErrorProperty.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/error-property/src/SeedErrorProperty/Core/Optional.cs b/seed/csharp-sdk/error-property/src/SeedErrorProperty/Core/Optional.cs new file mode 100644 index 000000000000..8653edd1c1b9 --- /dev/null +++ b/seed/csharp-sdk/error-property/src/SeedErrorProperty/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedErrorProperty.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/error-property/src/SeedErrorProperty/Core/OptionalAttribute.cs b/seed/csharp-sdk/error-property/src/SeedErrorProperty/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..3c7c6a0411d4 --- /dev/null +++ b/seed/csharp-sdk/error-property/src/SeedErrorProperty/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedErrorProperty.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/error-property/src/SeedErrorProperty/Core/RawClient.cs b/seed/csharp-sdk/error-property/src/SeedErrorProperty/Core/RawClient.cs index f0b20b16bc10..50a7de5f5e8c 100644 --- a/seed/csharp-sdk/error-property/src/SeedErrorProperty/Core/RawClient.cs +++ b/seed/csharp-sdk/error-property/src/SeedErrorProperty/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/errors/src/SeedErrors.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/errors/src/SeedErrors.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/errors/src/SeedErrors.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/errors/src/SeedErrors.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/errors/src/SeedErrors.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/errors/src/SeedErrors.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..20343511b696 --- /dev/null +++ b/seed/csharp-sdk/errors/src/SeedErrors.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedErrors.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/errors/src/SeedErrors/Core/JsonConfiguration.cs b/seed/csharp-sdk/errors/src/SeedErrors/Core/JsonConfiguration.cs index 3334dbcbdb04..0d76a9362cd3 100644 --- a/seed/csharp-sdk/errors/src/SeedErrors/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/errors/src/SeedErrors/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/errors/src/SeedErrors/Core/NullableAttribute.cs b/seed/csharp-sdk/errors/src/SeedErrors/Core/NullableAttribute.cs new file mode 100644 index 000000000000..92158a10c2f7 --- /dev/null +++ b/seed/csharp-sdk/errors/src/SeedErrors/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedErrors.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/errors/src/SeedErrors/Core/Optional.cs b/seed/csharp-sdk/errors/src/SeedErrors/Core/Optional.cs new file mode 100644 index 000000000000..42dee15d069c --- /dev/null +++ b/seed/csharp-sdk/errors/src/SeedErrors/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedErrors.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/errors/src/SeedErrors/Core/OptionalAttribute.cs b/seed/csharp-sdk/errors/src/SeedErrors/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..245eb5deb19c --- /dev/null +++ b/seed/csharp-sdk/errors/src/SeedErrors/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedErrors.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/errors/src/SeedErrors/Core/RawClient.cs b/seed/csharp-sdk/errors/src/SeedErrors/Core/RawClient.cs index 177d0be20e61..68561dc3131a 100644 --- a/seed/csharp-sdk/errors/src/SeedErrors/Core/RawClient.cs +++ b/seed/csharp-sdk/errors/src/SeedErrors/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/examples/no-custom-config/src/SeedExamples.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/examples/no-custom-config/src/SeedExamples.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/examples/no-custom-config/src/SeedExamples.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/examples/no-custom-config/src/SeedExamples.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/examples/no-custom-config/src/SeedExamples.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/examples/no-custom-config/src/SeedExamples.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..87545f70a9a2 --- /dev/null +++ b/seed/csharp-sdk/examples/no-custom-config/src/SeedExamples.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedExamples.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/examples/no-custom-config/src/SeedExamples/Core/JsonConfiguration.cs b/seed/csharp-sdk/examples/no-custom-config/src/SeedExamples/Core/JsonConfiguration.cs index 171596f8c343..6cc11f72b691 100644 --- a/seed/csharp-sdk/examples/no-custom-config/src/SeedExamples/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/examples/no-custom-config/src/SeedExamples/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/examples/no-custom-config/src/SeedExamples/Core/NullableAttribute.cs b/seed/csharp-sdk/examples/no-custom-config/src/SeedExamples/Core/NullableAttribute.cs new file mode 100644 index 000000000000..8f45ea8e6c6f --- /dev/null +++ b/seed/csharp-sdk/examples/no-custom-config/src/SeedExamples/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedExamples.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/examples/no-custom-config/src/SeedExamples/Core/Optional.cs b/seed/csharp-sdk/examples/no-custom-config/src/SeedExamples/Core/Optional.cs new file mode 100644 index 000000000000..79a44c30108f --- /dev/null +++ b/seed/csharp-sdk/examples/no-custom-config/src/SeedExamples/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedExamples.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/examples/no-custom-config/src/SeedExamples/Core/OptionalAttribute.cs b/seed/csharp-sdk/examples/no-custom-config/src/SeedExamples/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..5ddf0508d9ab --- /dev/null +++ b/seed/csharp-sdk/examples/no-custom-config/src/SeedExamples/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedExamples.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/examples/no-custom-config/src/SeedExamples/Core/RawClient.cs b/seed/csharp-sdk/examples/no-custom-config/src/SeedExamples/Core/RawClient.cs index 9099dbd01f44..cc1710dd8063 100644 --- a/seed/csharp-sdk/examples/no-custom-config/src/SeedExamples/Core/RawClient.cs +++ b/seed/csharp-sdk/examples/no-custom-config/src/SeedExamples/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/examples/readme-config/src/SeedExamples.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/examples/readme-config/src/SeedExamples.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/examples/readme-config/src/SeedExamples.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/examples/readme-config/src/SeedExamples.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/examples/readme-config/src/SeedExamples.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/examples/readme-config/src/SeedExamples.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..87545f70a9a2 --- /dev/null +++ b/seed/csharp-sdk/examples/readme-config/src/SeedExamples.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedExamples.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/examples/readme-config/src/SeedExamples/Core/JsonConfiguration.cs b/seed/csharp-sdk/examples/readme-config/src/SeedExamples/Core/JsonConfiguration.cs index 171596f8c343..6cc11f72b691 100644 --- a/seed/csharp-sdk/examples/readme-config/src/SeedExamples/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/examples/readme-config/src/SeedExamples/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/examples/readme-config/src/SeedExamples/Core/NullableAttribute.cs b/seed/csharp-sdk/examples/readme-config/src/SeedExamples/Core/NullableAttribute.cs new file mode 100644 index 000000000000..8f45ea8e6c6f --- /dev/null +++ b/seed/csharp-sdk/examples/readme-config/src/SeedExamples/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedExamples.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/examples/readme-config/src/SeedExamples/Core/Optional.cs b/seed/csharp-sdk/examples/readme-config/src/SeedExamples/Core/Optional.cs new file mode 100644 index 000000000000..79a44c30108f --- /dev/null +++ b/seed/csharp-sdk/examples/readme-config/src/SeedExamples/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedExamples.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/examples/readme-config/src/SeedExamples/Core/OptionalAttribute.cs b/seed/csharp-sdk/examples/readme-config/src/SeedExamples/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..5ddf0508d9ab --- /dev/null +++ b/seed/csharp-sdk/examples/readme-config/src/SeedExamples/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedExamples.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/examples/readme-config/src/SeedExamples/Core/RawClient.cs b/seed/csharp-sdk/examples/readme-config/src/SeedExamples/Core/RawClient.cs index 9099dbd01f44..cc1710dd8063 100644 --- a/seed/csharp-sdk/examples/readme-config/src/SeedExamples/Core/RawClient.cs +++ b/seed/csharp-sdk/examples/readme-config/src/SeedExamples/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/exhaustive/explicit-namespaces/src/SeedExhaustive.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/exhaustive/explicit-namespaces/src/SeedExhaustive.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/exhaustive/explicit-namespaces/src/SeedExhaustive.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/exhaustive/explicit-namespaces/src/SeedExhaustive.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/exhaustive/explicit-namespaces/src/SeedExhaustive.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/exhaustive/explicit-namespaces/src/SeedExhaustive.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..ac3c3e6583e2 --- /dev/null +++ b/seed/csharp-sdk/exhaustive/explicit-namespaces/src/SeedExhaustive.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedExhaustive.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/exhaustive/explicit-namespaces/src/SeedExhaustive/Core/JsonConfiguration.cs b/seed/csharp-sdk/exhaustive/explicit-namespaces/src/SeedExhaustive/Core/JsonConfiguration.cs index 61ca7c0fab87..a87e9506784a 100644 --- a/seed/csharp-sdk/exhaustive/explicit-namespaces/src/SeedExhaustive/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/exhaustive/explicit-namespaces/src/SeedExhaustive/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/exhaustive/explicit-namespaces/src/SeedExhaustive/Core/NullableAttribute.cs b/seed/csharp-sdk/exhaustive/explicit-namespaces/src/SeedExhaustive/Core/NullableAttribute.cs new file mode 100644 index 000000000000..84c02ac28cc4 --- /dev/null +++ b/seed/csharp-sdk/exhaustive/explicit-namespaces/src/SeedExhaustive/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedExhaustive.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/exhaustive/explicit-namespaces/src/SeedExhaustive/Core/Optional.cs b/seed/csharp-sdk/exhaustive/explicit-namespaces/src/SeedExhaustive/Core/Optional.cs new file mode 100644 index 000000000000..77bb5c49f148 --- /dev/null +++ b/seed/csharp-sdk/exhaustive/explicit-namespaces/src/SeedExhaustive/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedExhaustive.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/exhaustive/explicit-namespaces/src/SeedExhaustive/Core/OptionalAttribute.cs b/seed/csharp-sdk/exhaustive/explicit-namespaces/src/SeedExhaustive/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..104418968fe8 --- /dev/null +++ b/seed/csharp-sdk/exhaustive/explicit-namespaces/src/SeedExhaustive/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedExhaustive.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/exhaustive/explicit-namespaces/src/SeedExhaustive/Core/RawClient.cs b/seed/csharp-sdk/exhaustive/explicit-namespaces/src/SeedExhaustive/Core/RawClient.cs index f7ad0a5f8eb5..97dc3117e192 100644 --- a/seed/csharp-sdk/exhaustive/explicit-namespaces/src/SeedExhaustive/Core/RawClient.cs +++ b/seed/csharp-sdk/exhaustive/explicit-namespaces/src/SeedExhaustive/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/exhaustive/include-exception-handler/src/SeedExhaustive.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/exhaustive/include-exception-handler/src/SeedExhaustive.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/exhaustive/include-exception-handler/src/SeedExhaustive.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/exhaustive/include-exception-handler/src/SeedExhaustive.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/exhaustive/include-exception-handler/src/SeedExhaustive.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/exhaustive/include-exception-handler/src/SeedExhaustive.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..ac3c3e6583e2 --- /dev/null +++ b/seed/csharp-sdk/exhaustive/include-exception-handler/src/SeedExhaustive.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedExhaustive.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/exhaustive/include-exception-handler/src/SeedExhaustive/Core/JsonConfiguration.cs b/seed/csharp-sdk/exhaustive/include-exception-handler/src/SeedExhaustive/Core/JsonConfiguration.cs index 61ca7c0fab87..a87e9506784a 100644 --- a/seed/csharp-sdk/exhaustive/include-exception-handler/src/SeedExhaustive/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/exhaustive/include-exception-handler/src/SeedExhaustive/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/exhaustive/include-exception-handler/src/SeedExhaustive/Core/NullableAttribute.cs b/seed/csharp-sdk/exhaustive/include-exception-handler/src/SeedExhaustive/Core/NullableAttribute.cs new file mode 100644 index 000000000000..84c02ac28cc4 --- /dev/null +++ b/seed/csharp-sdk/exhaustive/include-exception-handler/src/SeedExhaustive/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedExhaustive.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/exhaustive/include-exception-handler/src/SeedExhaustive/Core/Optional.cs b/seed/csharp-sdk/exhaustive/include-exception-handler/src/SeedExhaustive/Core/Optional.cs new file mode 100644 index 000000000000..77bb5c49f148 --- /dev/null +++ b/seed/csharp-sdk/exhaustive/include-exception-handler/src/SeedExhaustive/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedExhaustive.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/exhaustive/include-exception-handler/src/SeedExhaustive/Core/OptionalAttribute.cs b/seed/csharp-sdk/exhaustive/include-exception-handler/src/SeedExhaustive/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..104418968fe8 --- /dev/null +++ b/seed/csharp-sdk/exhaustive/include-exception-handler/src/SeedExhaustive/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedExhaustive.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/exhaustive/include-exception-handler/src/SeedExhaustive/Core/RawClient.cs b/seed/csharp-sdk/exhaustive/include-exception-handler/src/SeedExhaustive/Core/RawClient.cs index f7ad0a5f8eb5..97dc3117e192 100644 --- a/seed/csharp-sdk/exhaustive/include-exception-handler/src/SeedExhaustive/Core/RawClient.cs +++ b/seed/csharp-sdk/exhaustive/include-exception-handler/src/SeedExhaustive/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/exhaustive/no-generate-error-types/src/SeedExhaustive.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/exhaustive/no-generate-error-types/src/SeedExhaustive.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/exhaustive/no-generate-error-types/src/SeedExhaustive.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/exhaustive/no-generate-error-types/src/SeedExhaustive.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/exhaustive/no-generate-error-types/src/SeedExhaustive.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/exhaustive/no-generate-error-types/src/SeedExhaustive.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..ac3c3e6583e2 --- /dev/null +++ b/seed/csharp-sdk/exhaustive/no-generate-error-types/src/SeedExhaustive.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedExhaustive.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/exhaustive/no-generate-error-types/src/SeedExhaustive/Core/JsonConfiguration.cs b/seed/csharp-sdk/exhaustive/no-generate-error-types/src/SeedExhaustive/Core/JsonConfiguration.cs index 61ca7c0fab87..a87e9506784a 100644 --- a/seed/csharp-sdk/exhaustive/no-generate-error-types/src/SeedExhaustive/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/exhaustive/no-generate-error-types/src/SeedExhaustive/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/exhaustive/no-generate-error-types/src/SeedExhaustive/Core/NullableAttribute.cs b/seed/csharp-sdk/exhaustive/no-generate-error-types/src/SeedExhaustive/Core/NullableAttribute.cs new file mode 100644 index 000000000000..84c02ac28cc4 --- /dev/null +++ b/seed/csharp-sdk/exhaustive/no-generate-error-types/src/SeedExhaustive/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedExhaustive.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/exhaustive/no-generate-error-types/src/SeedExhaustive/Core/Optional.cs b/seed/csharp-sdk/exhaustive/no-generate-error-types/src/SeedExhaustive/Core/Optional.cs new file mode 100644 index 000000000000..77bb5c49f148 --- /dev/null +++ b/seed/csharp-sdk/exhaustive/no-generate-error-types/src/SeedExhaustive/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedExhaustive.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/exhaustive/no-generate-error-types/src/SeedExhaustive/Core/OptionalAttribute.cs b/seed/csharp-sdk/exhaustive/no-generate-error-types/src/SeedExhaustive/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..104418968fe8 --- /dev/null +++ b/seed/csharp-sdk/exhaustive/no-generate-error-types/src/SeedExhaustive/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedExhaustive.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/exhaustive/no-generate-error-types/src/SeedExhaustive/Core/RawClient.cs b/seed/csharp-sdk/exhaustive/no-generate-error-types/src/SeedExhaustive/Core/RawClient.cs index f7ad0a5f8eb5..97dc3117e192 100644 --- a/seed/csharp-sdk/exhaustive/no-generate-error-types/src/SeedExhaustive/Core/RawClient.cs +++ b/seed/csharp-sdk/exhaustive/no-generate-error-types/src/SeedExhaustive/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/exhaustive/no-root-namespace-for-core-classes/src/SeedExhaustive.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/exhaustive/no-root-namespace-for-core-classes/src/SeedExhaustive.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/exhaustive/no-root-namespace-for-core-classes/src/SeedExhaustive.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/exhaustive/no-root-namespace-for-core-classes/src/SeedExhaustive.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/exhaustive/no-root-namespace-for-core-classes/src/SeedExhaustive.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/exhaustive/no-root-namespace-for-core-classes/src/SeedExhaustive.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..ac3c3e6583e2 --- /dev/null +++ b/seed/csharp-sdk/exhaustive/no-root-namespace-for-core-classes/src/SeedExhaustive.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedExhaustive.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/exhaustive/no-root-namespace-for-core-classes/src/SeedExhaustive/Core/JsonConfiguration.cs b/seed/csharp-sdk/exhaustive/no-root-namespace-for-core-classes/src/SeedExhaustive/Core/JsonConfiguration.cs index 61ca7c0fab87..a87e9506784a 100644 --- a/seed/csharp-sdk/exhaustive/no-root-namespace-for-core-classes/src/SeedExhaustive/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/exhaustive/no-root-namespace-for-core-classes/src/SeedExhaustive/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/exhaustive/no-root-namespace-for-core-classes/src/SeedExhaustive/Core/NullableAttribute.cs b/seed/csharp-sdk/exhaustive/no-root-namespace-for-core-classes/src/SeedExhaustive/Core/NullableAttribute.cs new file mode 100644 index 000000000000..84c02ac28cc4 --- /dev/null +++ b/seed/csharp-sdk/exhaustive/no-root-namespace-for-core-classes/src/SeedExhaustive/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedExhaustive.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/exhaustive/no-root-namespace-for-core-classes/src/SeedExhaustive/Core/Optional.cs b/seed/csharp-sdk/exhaustive/no-root-namespace-for-core-classes/src/SeedExhaustive/Core/Optional.cs new file mode 100644 index 000000000000..77bb5c49f148 --- /dev/null +++ b/seed/csharp-sdk/exhaustive/no-root-namespace-for-core-classes/src/SeedExhaustive/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedExhaustive.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/exhaustive/no-root-namespace-for-core-classes/src/SeedExhaustive/Core/OptionalAttribute.cs b/seed/csharp-sdk/exhaustive/no-root-namespace-for-core-classes/src/SeedExhaustive/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..104418968fe8 --- /dev/null +++ b/seed/csharp-sdk/exhaustive/no-root-namespace-for-core-classes/src/SeedExhaustive/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedExhaustive.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/exhaustive/no-root-namespace-for-core-classes/src/SeedExhaustive/Core/RawClient.cs b/seed/csharp-sdk/exhaustive/no-root-namespace-for-core-classes/src/SeedExhaustive/Core/RawClient.cs index f7ad0a5f8eb5..97dc3117e192 100644 --- a/seed/csharp-sdk/exhaustive/no-root-namespace-for-core-classes/src/SeedExhaustive/Core/RawClient.cs +++ b/seed/csharp-sdk/exhaustive/no-root-namespace-for-core-classes/src/SeedExhaustive/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/extends/src/SeedExtends.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/extends/src/SeedExtends.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/extends/src/SeedExtends.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/extends/src/SeedExtends.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/extends/src/SeedExtends.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/extends/src/SeedExtends.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..0f587804da60 --- /dev/null +++ b/seed/csharp-sdk/extends/src/SeedExtends.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedExtends.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/extends/src/SeedExtends/Core/JsonConfiguration.cs b/seed/csharp-sdk/extends/src/SeedExtends/Core/JsonConfiguration.cs index d0d6f010e4d5..f8574b68e731 100644 --- a/seed/csharp-sdk/extends/src/SeedExtends/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/extends/src/SeedExtends/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/extends/src/SeedExtends/Core/NullableAttribute.cs b/seed/csharp-sdk/extends/src/SeedExtends/Core/NullableAttribute.cs new file mode 100644 index 000000000000..d11f8fbf41fd --- /dev/null +++ b/seed/csharp-sdk/extends/src/SeedExtends/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedExtends.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/extends/src/SeedExtends/Core/Optional.cs b/seed/csharp-sdk/extends/src/SeedExtends/Core/Optional.cs new file mode 100644 index 000000000000..09aa83860ef1 --- /dev/null +++ b/seed/csharp-sdk/extends/src/SeedExtends/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedExtends.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/extends/src/SeedExtends/Core/OptionalAttribute.cs b/seed/csharp-sdk/extends/src/SeedExtends/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..0a9f23fc51c6 --- /dev/null +++ b/seed/csharp-sdk/extends/src/SeedExtends/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedExtends.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/extends/src/SeedExtends/Core/RawClient.cs b/seed/csharp-sdk/extends/src/SeedExtends/Core/RawClient.cs index edefd6649055..0755cd1d3df0 100644 --- a/seed/csharp-sdk/extends/src/SeedExtends/Core/RawClient.cs +++ b/seed/csharp-sdk/extends/src/SeedExtends/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/extra-properties/no-additional-properties/src/SeedExtraProperties.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/extra-properties/no-additional-properties/src/SeedExtraProperties.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/extra-properties/no-additional-properties/src/SeedExtraProperties.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/extra-properties/no-additional-properties/src/SeedExtraProperties.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/extra-properties/no-additional-properties/src/SeedExtraProperties.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/extra-properties/no-additional-properties/src/SeedExtraProperties.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..9d6a073bae41 --- /dev/null +++ b/seed/csharp-sdk/extra-properties/no-additional-properties/src/SeedExtraProperties.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedExtraProperties.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/extra-properties/no-additional-properties/src/SeedExtraProperties/Core/JsonConfiguration.cs b/seed/csharp-sdk/extra-properties/no-additional-properties/src/SeedExtraProperties/Core/JsonConfiguration.cs index ece264ecafe1..8bf4530aa49f 100644 --- a/seed/csharp-sdk/extra-properties/no-additional-properties/src/SeedExtraProperties/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/extra-properties/no-additional-properties/src/SeedExtraProperties/Core/JsonConfiguration.cs @@ -13,69 +13,132 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, TypeInfoResolver = new DefaultJsonTypeInfoResolver { - Modifiers = + Modifiers = { NullableOptionalModifier, JsonAccessAndIgnoreModifier }, + }, + }; + ConfigureJsonSerializerOptions(options); + JsonSerializerOptions = options; + } + + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) { - static typeInfo => + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; - foreach (var propertyInfo in typeInfo.Properties) + if (!capturedIsNullable) { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; } - }, - }, - }, - }; - ConfigureJsonSerializerOptions(options); - JsonSerializerOptions = options; + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } } static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); diff --git a/seed/csharp-sdk/extra-properties/no-additional-properties/src/SeedExtraProperties/Core/NullableAttribute.cs b/seed/csharp-sdk/extra-properties/no-additional-properties/src/SeedExtraProperties/Core/NullableAttribute.cs new file mode 100644 index 000000000000..ca086753dd33 --- /dev/null +++ b/seed/csharp-sdk/extra-properties/no-additional-properties/src/SeedExtraProperties/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedExtraProperties.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/extra-properties/no-additional-properties/src/SeedExtraProperties/Core/Optional.cs b/seed/csharp-sdk/extra-properties/no-additional-properties/src/SeedExtraProperties/Core/Optional.cs new file mode 100644 index 000000000000..fb7809088474 --- /dev/null +++ b/seed/csharp-sdk/extra-properties/no-additional-properties/src/SeedExtraProperties/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedExtraProperties.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/extra-properties/no-additional-properties/src/SeedExtraProperties/Core/OptionalAttribute.cs b/seed/csharp-sdk/extra-properties/no-additional-properties/src/SeedExtraProperties/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..6823b10279f4 --- /dev/null +++ b/seed/csharp-sdk/extra-properties/no-additional-properties/src/SeedExtraProperties/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedExtraProperties.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/extra-properties/no-additional-properties/src/SeedExtraProperties/Core/RawClient.cs b/seed/csharp-sdk/extra-properties/no-additional-properties/src/SeedExtraProperties/Core/RawClient.cs index 46c769ca9b8b..ee96085aa6b4 100644 --- a/seed/csharp-sdk/extra-properties/no-additional-properties/src/SeedExtraProperties/Core/RawClient.cs +++ b/seed/csharp-sdk/extra-properties/no-additional-properties/src/SeedExtraProperties/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/extra-properties/no-custom-config/src/SeedExtraProperties.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/extra-properties/no-custom-config/src/SeedExtraProperties.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/extra-properties/no-custom-config/src/SeedExtraProperties.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/extra-properties/no-custom-config/src/SeedExtraProperties.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/extra-properties/no-custom-config/src/SeedExtraProperties.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/extra-properties/no-custom-config/src/SeedExtraProperties.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..9d6a073bae41 --- /dev/null +++ b/seed/csharp-sdk/extra-properties/no-custom-config/src/SeedExtraProperties.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedExtraProperties.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/extra-properties/no-custom-config/src/SeedExtraProperties/Core/JsonConfiguration.cs b/seed/csharp-sdk/extra-properties/no-custom-config/src/SeedExtraProperties/Core/JsonConfiguration.cs index 51a5f38413d0..248744605bcb 100644 --- a/seed/csharp-sdk/extra-properties/no-custom-config/src/SeedExtraProperties/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/extra-properties/no-custom-config/src/SeedExtraProperties/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/extra-properties/no-custom-config/src/SeedExtraProperties/Core/NullableAttribute.cs b/seed/csharp-sdk/extra-properties/no-custom-config/src/SeedExtraProperties/Core/NullableAttribute.cs new file mode 100644 index 000000000000..ca086753dd33 --- /dev/null +++ b/seed/csharp-sdk/extra-properties/no-custom-config/src/SeedExtraProperties/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedExtraProperties.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/extra-properties/no-custom-config/src/SeedExtraProperties/Core/Optional.cs b/seed/csharp-sdk/extra-properties/no-custom-config/src/SeedExtraProperties/Core/Optional.cs new file mode 100644 index 000000000000..fb7809088474 --- /dev/null +++ b/seed/csharp-sdk/extra-properties/no-custom-config/src/SeedExtraProperties/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedExtraProperties.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/extra-properties/no-custom-config/src/SeedExtraProperties/Core/OptionalAttribute.cs b/seed/csharp-sdk/extra-properties/no-custom-config/src/SeedExtraProperties/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..6823b10279f4 --- /dev/null +++ b/seed/csharp-sdk/extra-properties/no-custom-config/src/SeedExtraProperties/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedExtraProperties.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/extra-properties/no-custom-config/src/SeedExtraProperties/Core/RawClient.cs b/seed/csharp-sdk/extra-properties/no-custom-config/src/SeedExtraProperties/Core/RawClient.cs index 46c769ca9b8b..ee96085aa6b4 100644 --- a/seed/csharp-sdk/extra-properties/no-custom-config/src/SeedExtraProperties/Core/RawClient.cs +++ b/seed/csharp-sdk/extra-properties/no-custom-config/src/SeedExtraProperties/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/file-download/src/SeedFileDownload.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/file-download/src/SeedFileDownload.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/file-download/src/SeedFileDownload.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/file-download/src/SeedFileDownload.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/file-download/src/SeedFileDownload.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/file-download/src/SeedFileDownload.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..bb8d3b099d64 --- /dev/null +++ b/seed/csharp-sdk/file-download/src/SeedFileDownload.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedFileDownload.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/file-download/src/SeedFileDownload/Core/JsonConfiguration.cs b/seed/csharp-sdk/file-download/src/SeedFileDownload/Core/JsonConfiguration.cs index 801ebfd4dd39..dce03d94756e 100644 --- a/seed/csharp-sdk/file-download/src/SeedFileDownload/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/file-download/src/SeedFileDownload/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/file-download/src/SeedFileDownload/Core/NullableAttribute.cs b/seed/csharp-sdk/file-download/src/SeedFileDownload/Core/NullableAttribute.cs new file mode 100644 index 000000000000..018586898e83 --- /dev/null +++ b/seed/csharp-sdk/file-download/src/SeedFileDownload/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedFileDownload.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/file-download/src/SeedFileDownload/Core/Optional.cs b/seed/csharp-sdk/file-download/src/SeedFileDownload/Core/Optional.cs new file mode 100644 index 000000000000..f4eb261b90ce --- /dev/null +++ b/seed/csharp-sdk/file-download/src/SeedFileDownload/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedFileDownload.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/file-download/src/SeedFileDownload/Core/OptionalAttribute.cs b/seed/csharp-sdk/file-download/src/SeedFileDownload/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..4947ea558fa0 --- /dev/null +++ b/seed/csharp-sdk/file-download/src/SeedFileDownload/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedFileDownload.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/file-download/src/SeedFileDownload/Core/RawClient.cs b/seed/csharp-sdk/file-download/src/SeedFileDownload/Core/RawClient.cs index 0c23d6bee767..05dfdcddc46f 100644 --- a/seed/csharp-sdk/file-download/src/SeedFileDownload/Core/RawClient.cs +++ b/seed/csharp-sdk/file-download/src/SeedFileDownload/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/file-upload-openapi/src/SeedApi.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/file-upload-openapi/src/SeedApi.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/file-upload-openapi/src/SeedApi.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/file-upload-openapi/src/SeedApi.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/file-upload-openapi/src/SeedApi.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/file-upload-openapi/src/SeedApi.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..dc732084c15f --- /dev/null +++ b/seed/csharp-sdk/file-upload-openapi/src/SeedApi.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedApi.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/file-upload-openapi/src/SeedApi/Core/JsonConfiguration.cs b/seed/csharp-sdk/file-upload-openapi/src/SeedApi/Core/JsonConfiguration.cs index 321193691456..df6b2c46945d 100644 --- a/seed/csharp-sdk/file-upload-openapi/src/SeedApi/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/file-upload-openapi/src/SeedApi/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/file-upload-openapi/src/SeedApi/Core/NullableAttribute.cs b/seed/csharp-sdk/file-upload-openapi/src/SeedApi/Core/NullableAttribute.cs new file mode 100644 index 000000000000..8fca30535a2a --- /dev/null +++ b/seed/csharp-sdk/file-upload-openapi/src/SeedApi/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedApi.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/file-upload-openapi/src/SeedApi/Core/Optional.cs b/seed/csharp-sdk/file-upload-openapi/src/SeedApi/Core/Optional.cs new file mode 100644 index 000000000000..4a9691feca1f --- /dev/null +++ b/seed/csharp-sdk/file-upload-openapi/src/SeedApi/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedApi.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/file-upload-openapi/src/SeedApi/Core/OptionalAttribute.cs b/seed/csharp-sdk/file-upload-openapi/src/SeedApi/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..b2bc05f7244f --- /dev/null +++ b/seed/csharp-sdk/file-upload-openapi/src/SeedApi/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedApi.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/file-upload-openapi/src/SeedApi/Core/RawClient.cs b/seed/csharp-sdk/file-upload-openapi/src/SeedApi/Core/RawClient.cs index 72e34bbc0e89..b60968e4eb03 100644 --- a/seed/csharp-sdk/file-upload-openapi/src/SeedApi/Core/RawClient.cs +++ b/seed/csharp-sdk/file-upload-openapi/src/SeedApi/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/file-upload/src/SeedFileUpload.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/file-upload/src/SeedFileUpload.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/file-upload/src/SeedFileUpload.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/file-upload/src/SeedFileUpload.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/file-upload/src/SeedFileUpload.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/file-upload/src/SeedFileUpload.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..08d0ef20468a --- /dev/null +++ b/seed/csharp-sdk/file-upload/src/SeedFileUpload.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedFileUpload.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/file-upload/src/SeedFileUpload/Core/JsonConfiguration.cs b/seed/csharp-sdk/file-upload/src/SeedFileUpload/Core/JsonConfiguration.cs index e3e0fd614b20..94b4776dba7d 100644 --- a/seed/csharp-sdk/file-upload/src/SeedFileUpload/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/file-upload/src/SeedFileUpload/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/file-upload/src/SeedFileUpload/Core/NullableAttribute.cs b/seed/csharp-sdk/file-upload/src/SeedFileUpload/Core/NullableAttribute.cs new file mode 100644 index 000000000000..7a27bedda8e5 --- /dev/null +++ b/seed/csharp-sdk/file-upload/src/SeedFileUpload/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedFileUpload.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/file-upload/src/SeedFileUpload/Core/Optional.cs b/seed/csharp-sdk/file-upload/src/SeedFileUpload/Core/Optional.cs new file mode 100644 index 000000000000..58222a938d09 --- /dev/null +++ b/seed/csharp-sdk/file-upload/src/SeedFileUpload/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedFileUpload.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/file-upload/src/SeedFileUpload/Core/OptionalAttribute.cs b/seed/csharp-sdk/file-upload/src/SeedFileUpload/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..270ff11e6a2c --- /dev/null +++ b/seed/csharp-sdk/file-upload/src/SeedFileUpload/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedFileUpload.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/file-upload/src/SeedFileUpload/Core/RawClient.cs b/seed/csharp-sdk/file-upload/src/SeedFileUpload/Core/RawClient.cs index c33d3248688a..8abd8a4610e5 100644 --- a/seed/csharp-sdk/file-upload/src/SeedFileUpload/Core/RawClient.cs +++ b/seed/csharp-sdk/file-upload/src/SeedFileUpload/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/folders/src/SeedApi.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/folders/src/SeedApi.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/folders/src/SeedApi.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/folders/src/SeedApi.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/folders/src/SeedApi.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/folders/src/SeedApi.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..dc732084c15f --- /dev/null +++ b/seed/csharp-sdk/folders/src/SeedApi.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedApi.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/folders/src/SeedApi/Core/JsonConfiguration.cs b/seed/csharp-sdk/folders/src/SeedApi/Core/JsonConfiguration.cs index 321193691456..df6b2c46945d 100644 --- a/seed/csharp-sdk/folders/src/SeedApi/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/folders/src/SeedApi/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/folders/src/SeedApi/Core/NullableAttribute.cs b/seed/csharp-sdk/folders/src/SeedApi/Core/NullableAttribute.cs new file mode 100644 index 000000000000..8fca30535a2a --- /dev/null +++ b/seed/csharp-sdk/folders/src/SeedApi/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedApi.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/folders/src/SeedApi/Core/Optional.cs b/seed/csharp-sdk/folders/src/SeedApi/Core/Optional.cs new file mode 100644 index 000000000000..4a9691feca1f --- /dev/null +++ b/seed/csharp-sdk/folders/src/SeedApi/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedApi.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/folders/src/SeedApi/Core/OptionalAttribute.cs b/seed/csharp-sdk/folders/src/SeedApi/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..b2bc05f7244f --- /dev/null +++ b/seed/csharp-sdk/folders/src/SeedApi/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedApi.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/folders/src/SeedApi/Core/RawClient.cs b/seed/csharp-sdk/folders/src/SeedApi/Core/RawClient.cs index 72e34bbc0e89..b60968e4eb03 100644 --- a/seed/csharp-sdk/folders/src/SeedApi/Core/RawClient.cs +++ b/seed/csharp-sdk/folders/src/SeedApi/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/header-auth-environment-variable/src/SeedHeaderTokenEnvironmentVariable.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/header-auth-environment-variable/src/SeedHeaderTokenEnvironmentVariable.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/header-auth-environment-variable/src/SeedHeaderTokenEnvironmentVariable.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/header-auth-environment-variable/src/SeedHeaderTokenEnvironmentVariable.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/header-auth-environment-variable/src/SeedHeaderTokenEnvironmentVariable.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/header-auth-environment-variable/src/SeedHeaderTokenEnvironmentVariable.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..51270b1dd21c --- /dev/null +++ b/seed/csharp-sdk/header-auth-environment-variable/src/SeedHeaderTokenEnvironmentVariable.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedHeaderTokenEnvironmentVariable.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/header-auth-environment-variable/src/SeedHeaderTokenEnvironmentVariable/Core/JsonConfiguration.cs b/seed/csharp-sdk/header-auth-environment-variable/src/SeedHeaderTokenEnvironmentVariable/Core/JsonConfiguration.cs index 21b14d880495..400495afecca 100644 --- a/seed/csharp-sdk/header-auth-environment-variable/src/SeedHeaderTokenEnvironmentVariable/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/header-auth-environment-variable/src/SeedHeaderTokenEnvironmentVariable/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/header-auth-environment-variable/src/SeedHeaderTokenEnvironmentVariable/Core/NullableAttribute.cs b/seed/csharp-sdk/header-auth-environment-variable/src/SeedHeaderTokenEnvironmentVariable/Core/NullableAttribute.cs new file mode 100644 index 000000000000..385f052cfdda --- /dev/null +++ b/seed/csharp-sdk/header-auth-environment-variable/src/SeedHeaderTokenEnvironmentVariable/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedHeaderTokenEnvironmentVariable.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/header-auth-environment-variable/src/SeedHeaderTokenEnvironmentVariable/Core/Optional.cs b/seed/csharp-sdk/header-auth-environment-variable/src/SeedHeaderTokenEnvironmentVariable/Core/Optional.cs new file mode 100644 index 000000000000..0de481c0bccf --- /dev/null +++ b/seed/csharp-sdk/header-auth-environment-variable/src/SeedHeaderTokenEnvironmentVariable/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedHeaderTokenEnvironmentVariable.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/header-auth-environment-variable/src/SeedHeaderTokenEnvironmentVariable/Core/OptionalAttribute.cs b/seed/csharp-sdk/header-auth-environment-variable/src/SeedHeaderTokenEnvironmentVariable/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..5bb1819cd3b3 --- /dev/null +++ b/seed/csharp-sdk/header-auth-environment-variable/src/SeedHeaderTokenEnvironmentVariable/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedHeaderTokenEnvironmentVariable.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/header-auth-environment-variable/src/SeedHeaderTokenEnvironmentVariable/Core/RawClient.cs b/seed/csharp-sdk/header-auth-environment-variable/src/SeedHeaderTokenEnvironmentVariable/Core/RawClient.cs index b56a7e30568c..49d8e1f582bd 100644 --- a/seed/csharp-sdk/header-auth-environment-variable/src/SeedHeaderTokenEnvironmentVariable/Core/RawClient.cs +++ b/seed/csharp-sdk/header-auth-environment-variable/src/SeedHeaderTokenEnvironmentVariable/Core/RawClient.cs @@ -357,6 +357,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/header-auth/src/SeedHeaderToken.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/header-auth/src/SeedHeaderToken.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/header-auth/src/SeedHeaderToken.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/header-auth/src/SeedHeaderToken.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/header-auth/src/SeedHeaderToken.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/header-auth/src/SeedHeaderToken.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..c07818411624 --- /dev/null +++ b/seed/csharp-sdk/header-auth/src/SeedHeaderToken.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedHeaderToken.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/header-auth/src/SeedHeaderToken/Core/JsonConfiguration.cs b/seed/csharp-sdk/header-auth/src/SeedHeaderToken/Core/JsonConfiguration.cs index e9be26e97341..6798513363f4 100644 --- a/seed/csharp-sdk/header-auth/src/SeedHeaderToken/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/header-auth/src/SeedHeaderToken/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/header-auth/src/SeedHeaderToken/Core/NullableAttribute.cs b/seed/csharp-sdk/header-auth/src/SeedHeaderToken/Core/NullableAttribute.cs new file mode 100644 index 000000000000..41b913018169 --- /dev/null +++ b/seed/csharp-sdk/header-auth/src/SeedHeaderToken/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedHeaderToken.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/header-auth/src/SeedHeaderToken/Core/Optional.cs b/seed/csharp-sdk/header-auth/src/SeedHeaderToken/Core/Optional.cs new file mode 100644 index 000000000000..78675c5b49b1 --- /dev/null +++ b/seed/csharp-sdk/header-auth/src/SeedHeaderToken/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedHeaderToken.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/header-auth/src/SeedHeaderToken/Core/OptionalAttribute.cs b/seed/csharp-sdk/header-auth/src/SeedHeaderToken/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..bbbcc0524b3a --- /dev/null +++ b/seed/csharp-sdk/header-auth/src/SeedHeaderToken/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedHeaderToken.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/header-auth/src/SeedHeaderToken/Core/RawClient.cs b/seed/csharp-sdk/header-auth/src/SeedHeaderToken/Core/RawClient.cs index d08ea5ce33df..a1799364c473 100644 --- a/seed/csharp-sdk/header-auth/src/SeedHeaderToken/Core/RawClient.cs +++ b/seed/csharp-sdk/header-auth/src/SeedHeaderToken/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/http-head/src/SeedHttpHead.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/http-head/src/SeedHttpHead.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/http-head/src/SeedHttpHead.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/http-head/src/SeedHttpHead.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/http-head/src/SeedHttpHead.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/http-head/src/SeedHttpHead.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..7fd7db39de4f --- /dev/null +++ b/seed/csharp-sdk/http-head/src/SeedHttpHead.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedHttpHead.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/http-head/src/SeedHttpHead/Core/JsonConfiguration.cs b/seed/csharp-sdk/http-head/src/SeedHttpHead/Core/JsonConfiguration.cs index f32fbf14f0df..5e81c2448e5e 100644 --- a/seed/csharp-sdk/http-head/src/SeedHttpHead/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/http-head/src/SeedHttpHead/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/http-head/src/SeedHttpHead/Core/NullableAttribute.cs b/seed/csharp-sdk/http-head/src/SeedHttpHead/Core/NullableAttribute.cs new file mode 100644 index 000000000000..cbd44bd6bd5b --- /dev/null +++ b/seed/csharp-sdk/http-head/src/SeedHttpHead/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedHttpHead.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/http-head/src/SeedHttpHead/Core/Optional.cs b/seed/csharp-sdk/http-head/src/SeedHttpHead/Core/Optional.cs new file mode 100644 index 000000000000..3908edd81b81 --- /dev/null +++ b/seed/csharp-sdk/http-head/src/SeedHttpHead/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedHttpHead.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/http-head/src/SeedHttpHead/Core/OptionalAttribute.cs b/seed/csharp-sdk/http-head/src/SeedHttpHead/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..f11a0f937068 --- /dev/null +++ b/seed/csharp-sdk/http-head/src/SeedHttpHead/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedHttpHead.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/http-head/src/SeedHttpHead/Core/RawClient.cs b/seed/csharp-sdk/http-head/src/SeedHttpHead/Core/RawClient.cs index 30db8605e321..581b4e0114f3 100644 --- a/seed/csharp-sdk/http-head/src/SeedHttpHead/Core/RawClient.cs +++ b/seed/csharp-sdk/http-head/src/SeedHttpHead/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/idempotency-headers/src/SeedIdempotencyHeaders.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/idempotency-headers/src/SeedIdempotencyHeaders.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/idempotency-headers/src/SeedIdempotencyHeaders.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/idempotency-headers/src/SeedIdempotencyHeaders.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/idempotency-headers/src/SeedIdempotencyHeaders.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/idempotency-headers/src/SeedIdempotencyHeaders.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..e6c7f12432dc --- /dev/null +++ b/seed/csharp-sdk/idempotency-headers/src/SeedIdempotencyHeaders.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedIdempotencyHeaders.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/idempotency-headers/src/SeedIdempotencyHeaders/Core/JsonConfiguration.cs b/seed/csharp-sdk/idempotency-headers/src/SeedIdempotencyHeaders/Core/JsonConfiguration.cs index c00b9aeee733..9d0ab513a535 100644 --- a/seed/csharp-sdk/idempotency-headers/src/SeedIdempotencyHeaders/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/idempotency-headers/src/SeedIdempotencyHeaders/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/idempotency-headers/src/SeedIdempotencyHeaders/Core/NullableAttribute.cs b/seed/csharp-sdk/idempotency-headers/src/SeedIdempotencyHeaders/Core/NullableAttribute.cs new file mode 100644 index 000000000000..8eab7a87808b --- /dev/null +++ b/seed/csharp-sdk/idempotency-headers/src/SeedIdempotencyHeaders/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedIdempotencyHeaders.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/idempotency-headers/src/SeedIdempotencyHeaders/Core/Optional.cs b/seed/csharp-sdk/idempotency-headers/src/SeedIdempotencyHeaders/Core/Optional.cs new file mode 100644 index 000000000000..ce67851b9d49 --- /dev/null +++ b/seed/csharp-sdk/idempotency-headers/src/SeedIdempotencyHeaders/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedIdempotencyHeaders.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/idempotency-headers/src/SeedIdempotencyHeaders/Core/OptionalAttribute.cs b/seed/csharp-sdk/idempotency-headers/src/SeedIdempotencyHeaders/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..c7c12473feed --- /dev/null +++ b/seed/csharp-sdk/idempotency-headers/src/SeedIdempotencyHeaders/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedIdempotencyHeaders.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/idempotency-headers/src/SeedIdempotencyHeaders/Core/RawClient.cs b/seed/csharp-sdk/idempotency-headers/src/SeedIdempotencyHeaders/Core/RawClient.cs index 8607dbdb97cb..94bbd07ba393 100644 --- a/seed/csharp-sdk/idempotency-headers/src/SeedIdempotencyHeaders/Core/RawClient.cs +++ b/seed/csharp-sdk/idempotency-headers/src/SeedIdempotencyHeaders/Core/RawClient.cs @@ -361,6 +361,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/imdb/exception-class-names/src/SeedApi.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/imdb/exception-class-names/src/SeedApi.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/imdb/exception-class-names/src/SeedApi.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/imdb/exception-class-names/src/SeedApi.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/imdb/exception-class-names/src/SeedApi.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/imdb/exception-class-names/src/SeedApi.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..dc732084c15f --- /dev/null +++ b/seed/csharp-sdk/imdb/exception-class-names/src/SeedApi.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedApi.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/imdb/exception-class-names/src/SeedApi/Core/JsonConfiguration.cs b/seed/csharp-sdk/imdb/exception-class-names/src/SeedApi/Core/JsonConfiguration.cs index 321193691456..df6b2c46945d 100644 --- a/seed/csharp-sdk/imdb/exception-class-names/src/SeedApi/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/imdb/exception-class-names/src/SeedApi/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/imdb/exception-class-names/src/SeedApi/Core/NullableAttribute.cs b/seed/csharp-sdk/imdb/exception-class-names/src/SeedApi/Core/NullableAttribute.cs new file mode 100644 index 000000000000..8fca30535a2a --- /dev/null +++ b/seed/csharp-sdk/imdb/exception-class-names/src/SeedApi/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedApi.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/imdb/exception-class-names/src/SeedApi/Core/Optional.cs b/seed/csharp-sdk/imdb/exception-class-names/src/SeedApi/Core/Optional.cs new file mode 100644 index 000000000000..4a9691feca1f --- /dev/null +++ b/seed/csharp-sdk/imdb/exception-class-names/src/SeedApi/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedApi.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/imdb/exception-class-names/src/SeedApi/Core/OptionalAttribute.cs b/seed/csharp-sdk/imdb/exception-class-names/src/SeedApi/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..b2bc05f7244f --- /dev/null +++ b/seed/csharp-sdk/imdb/exception-class-names/src/SeedApi/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedApi.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/imdb/exception-class-names/src/SeedApi/Core/RawClient.cs b/seed/csharp-sdk/imdb/exception-class-names/src/SeedApi/Core/RawClient.cs index 72e34bbc0e89..b60968e4eb03 100644 --- a/seed/csharp-sdk/imdb/exception-class-names/src/SeedApi/Core/RawClient.cs +++ b/seed/csharp-sdk/imdb/exception-class-names/src/SeedApi/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/imdb/exported-client-class-name/src/SeedApi.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/imdb/exported-client-class-name/src/SeedApi.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/imdb/exported-client-class-name/src/SeedApi.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/imdb/exported-client-class-name/src/SeedApi.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/imdb/exported-client-class-name/src/SeedApi.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/imdb/exported-client-class-name/src/SeedApi.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..dc732084c15f --- /dev/null +++ b/seed/csharp-sdk/imdb/exported-client-class-name/src/SeedApi.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedApi.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/imdb/exported-client-class-name/src/SeedApi/Core/JsonConfiguration.cs b/seed/csharp-sdk/imdb/exported-client-class-name/src/SeedApi/Core/JsonConfiguration.cs index 321193691456..df6b2c46945d 100644 --- a/seed/csharp-sdk/imdb/exported-client-class-name/src/SeedApi/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/imdb/exported-client-class-name/src/SeedApi/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/imdb/exported-client-class-name/src/SeedApi/Core/NullableAttribute.cs b/seed/csharp-sdk/imdb/exported-client-class-name/src/SeedApi/Core/NullableAttribute.cs new file mode 100644 index 000000000000..8fca30535a2a --- /dev/null +++ b/seed/csharp-sdk/imdb/exported-client-class-name/src/SeedApi/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedApi.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/imdb/exported-client-class-name/src/SeedApi/Core/Optional.cs b/seed/csharp-sdk/imdb/exported-client-class-name/src/SeedApi/Core/Optional.cs new file mode 100644 index 000000000000..4a9691feca1f --- /dev/null +++ b/seed/csharp-sdk/imdb/exported-client-class-name/src/SeedApi/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedApi.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/imdb/exported-client-class-name/src/SeedApi/Core/OptionalAttribute.cs b/seed/csharp-sdk/imdb/exported-client-class-name/src/SeedApi/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..b2bc05f7244f --- /dev/null +++ b/seed/csharp-sdk/imdb/exported-client-class-name/src/SeedApi/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedApi.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/imdb/exported-client-class-name/src/SeedApi/Core/RawClient.cs b/seed/csharp-sdk/imdb/exported-client-class-name/src/SeedApi/Core/RawClient.cs index 72e34bbc0e89..b60968e4eb03 100644 --- a/seed/csharp-sdk/imdb/exported-client-class-name/src/SeedApi/Core/RawClient.cs +++ b/seed/csharp-sdk/imdb/exported-client-class-name/src/SeedApi/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/imdb/extra-dependencies/src/SeedApi.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/imdb/extra-dependencies/src/SeedApi.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/imdb/extra-dependencies/src/SeedApi.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/imdb/extra-dependencies/src/SeedApi.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/imdb/extra-dependencies/src/SeedApi.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/imdb/extra-dependencies/src/SeedApi.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..dc732084c15f --- /dev/null +++ b/seed/csharp-sdk/imdb/extra-dependencies/src/SeedApi.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedApi.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/imdb/extra-dependencies/src/SeedApi/Core/JsonConfiguration.cs b/seed/csharp-sdk/imdb/extra-dependencies/src/SeedApi/Core/JsonConfiguration.cs index 321193691456..df6b2c46945d 100644 --- a/seed/csharp-sdk/imdb/extra-dependencies/src/SeedApi/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/imdb/extra-dependencies/src/SeedApi/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/imdb/extra-dependencies/src/SeedApi/Core/NullableAttribute.cs b/seed/csharp-sdk/imdb/extra-dependencies/src/SeedApi/Core/NullableAttribute.cs new file mode 100644 index 000000000000..8fca30535a2a --- /dev/null +++ b/seed/csharp-sdk/imdb/extra-dependencies/src/SeedApi/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedApi.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/imdb/extra-dependencies/src/SeedApi/Core/Optional.cs b/seed/csharp-sdk/imdb/extra-dependencies/src/SeedApi/Core/Optional.cs new file mode 100644 index 000000000000..4a9691feca1f --- /dev/null +++ b/seed/csharp-sdk/imdb/extra-dependencies/src/SeedApi/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedApi.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/imdb/extra-dependencies/src/SeedApi/Core/OptionalAttribute.cs b/seed/csharp-sdk/imdb/extra-dependencies/src/SeedApi/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..b2bc05f7244f --- /dev/null +++ b/seed/csharp-sdk/imdb/extra-dependencies/src/SeedApi/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedApi.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/imdb/extra-dependencies/src/SeedApi/Core/RawClient.cs b/seed/csharp-sdk/imdb/extra-dependencies/src/SeedApi/Core/RawClient.cs index 72e34bbc0e89..b60968e4eb03 100644 --- a/seed/csharp-sdk/imdb/extra-dependencies/src/SeedApi/Core/RawClient.cs +++ b/seed/csharp-sdk/imdb/extra-dependencies/src/SeedApi/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/imdb/include-exception-handler/src/SeedApi.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/imdb/include-exception-handler/src/SeedApi.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/imdb/include-exception-handler/src/SeedApi.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/imdb/include-exception-handler/src/SeedApi.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/imdb/include-exception-handler/src/SeedApi.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/imdb/include-exception-handler/src/SeedApi.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..dc732084c15f --- /dev/null +++ b/seed/csharp-sdk/imdb/include-exception-handler/src/SeedApi.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedApi.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/imdb/include-exception-handler/src/SeedApi/Core/JsonConfiguration.cs b/seed/csharp-sdk/imdb/include-exception-handler/src/SeedApi/Core/JsonConfiguration.cs index 321193691456..df6b2c46945d 100644 --- a/seed/csharp-sdk/imdb/include-exception-handler/src/SeedApi/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/imdb/include-exception-handler/src/SeedApi/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/imdb/include-exception-handler/src/SeedApi/Core/NullableAttribute.cs b/seed/csharp-sdk/imdb/include-exception-handler/src/SeedApi/Core/NullableAttribute.cs new file mode 100644 index 000000000000..8fca30535a2a --- /dev/null +++ b/seed/csharp-sdk/imdb/include-exception-handler/src/SeedApi/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedApi.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/imdb/include-exception-handler/src/SeedApi/Core/Optional.cs b/seed/csharp-sdk/imdb/include-exception-handler/src/SeedApi/Core/Optional.cs new file mode 100644 index 000000000000..4a9691feca1f --- /dev/null +++ b/seed/csharp-sdk/imdb/include-exception-handler/src/SeedApi/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedApi.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/imdb/include-exception-handler/src/SeedApi/Core/OptionalAttribute.cs b/seed/csharp-sdk/imdb/include-exception-handler/src/SeedApi/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..b2bc05f7244f --- /dev/null +++ b/seed/csharp-sdk/imdb/include-exception-handler/src/SeedApi/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedApi.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/imdb/include-exception-handler/src/SeedApi/Core/RawClient.cs b/seed/csharp-sdk/imdb/include-exception-handler/src/SeedApi/Core/RawClient.cs index 72e34bbc0e89..b60968e4eb03 100644 --- a/seed/csharp-sdk/imdb/include-exception-handler/src/SeedApi/Core/RawClient.cs +++ b/seed/csharp-sdk/imdb/include-exception-handler/src/SeedApi/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/imdb/include-exception-handler/src/SeedApi/Core/SeedApiExceptionInterceptor.cs b/seed/csharp-sdk/imdb/include-exception-handler/src/SeedApi/Core/SeedApiExceptionInterceptor.cs index 690908560a05..bb49a1d081f5 100644 --- a/seed/csharp-sdk/imdb/include-exception-handler/src/SeedApi/Core/SeedApiExceptionInterceptor.cs +++ b/seed/csharp-sdk/imdb/include-exception-handler/src/SeedApi/Core/SeedApiExceptionInterceptor.cs @@ -1,3 +1,5 @@ +using SeedApi; + namespace SeedApi.Core; /// @@ -5,6 +7,13 @@ namespace SeedApi.Core; /// public class SeedApiExceptionInterceptor : IExceptionInterceptor { + private readonly ClientOptions _clientOptions; + + public SeedApiExceptionInterceptor(ClientOptions clientOptions) + { + _clientOptions = clientOptions; + } + /// /// Intercepts an exception and returns it after capturing. /// diff --git a/seed/csharp-sdk/imdb/include-exception-handler/src/SeedApi/SeedApiClient.cs b/seed/csharp-sdk/imdb/include-exception-handler/src/SeedApi/SeedApiClient.cs index 9f29f5ac7416..35afdd0620a1 100644 --- a/seed/csharp-sdk/imdb/include-exception-handler/src/SeedApi/SeedApiClient.cs +++ b/seed/csharp-sdk/imdb/include-exception-handler/src/SeedApi/SeedApiClient.cs @@ -22,7 +22,7 @@ public SeedApiClient(string token, ClientOptions? clientOptions = null) ); clientOptions ??= new ClientOptions(); clientOptions.ExceptionHandler = new ExceptionHandler( - new SeedApiExceptionInterceptor() + new SeedApiExceptionInterceptor(clientOptions) ); foreach (var header in defaultHeaders) { @@ -36,7 +36,7 @@ public SeedApiClient(string token, ClientOptions? clientOptions = null) } catch (Exception ex) { - var interceptor = new SeedApiExceptionInterceptor(); + var interceptor = new SeedApiExceptionInterceptor(clientOptions); interceptor.Intercept(ex); throw; } diff --git a/seed/csharp-sdk/imdb/no-custom-config/src/SeedApi.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/imdb/no-custom-config/src/SeedApi.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/imdb/no-custom-config/src/SeedApi.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/imdb/no-custom-config/src/SeedApi.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/imdb/no-custom-config/src/SeedApi.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/imdb/no-custom-config/src/SeedApi.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..dc732084c15f --- /dev/null +++ b/seed/csharp-sdk/imdb/no-custom-config/src/SeedApi.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedApi.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/imdb/no-custom-config/src/SeedApi/Core/JsonConfiguration.cs b/seed/csharp-sdk/imdb/no-custom-config/src/SeedApi/Core/JsonConfiguration.cs index 321193691456..df6b2c46945d 100644 --- a/seed/csharp-sdk/imdb/no-custom-config/src/SeedApi/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/imdb/no-custom-config/src/SeedApi/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/imdb/no-custom-config/src/SeedApi/Core/NullableAttribute.cs b/seed/csharp-sdk/imdb/no-custom-config/src/SeedApi/Core/NullableAttribute.cs new file mode 100644 index 000000000000..8fca30535a2a --- /dev/null +++ b/seed/csharp-sdk/imdb/no-custom-config/src/SeedApi/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedApi.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/imdb/no-custom-config/src/SeedApi/Core/Optional.cs b/seed/csharp-sdk/imdb/no-custom-config/src/SeedApi/Core/Optional.cs new file mode 100644 index 000000000000..4a9691feca1f --- /dev/null +++ b/seed/csharp-sdk/imdb/no-custom-config/src/SeedApi/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedApi.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/imdb/no-custom-config/src/SeedApi/Core/OptionalAttribute.cs b/seed/csharp-sdk/imdb/no-custom-config/src/SeedApi/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..b2bc05f7244f --- /dev/null +++ b/seed/csharp-sdk/imdb/no-custom-config/src/SeedApi/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedApi.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/imdb/no-custom-config/src/SeedApi/Core/RawClient.cs b/seed/csharp-sdk/imdb/no-custom-config/src/SeedApi/Core/RawClient.cs index 72e34bbc0e89..b60968e4eb03 100644 --- a/seed/csharp-sdk/imdb/no-custom-config/src/SeedApi/Core/RawClient.cs +++ b/seed/csharp-sdk/imdb/no-custom-config/src/SeedApi/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/inferred-auth-explicit/src/SeedInferredAuthExplicit.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/inferred-auth-explicit/src/SeedInferredAuthExplicit.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/inferred-auth-explicit/src/SeedInferredAuthExplicit.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/inferred-auth-explicit/src/SeedInferredAuthExplicit.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/inferred-auth-explicit/src/SeedInferredAuthExplicit.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/inferred-auth-explicit/src/SeedInferredAuthExplicit.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..95cfad239f82 --- /dev/null +++ b/seed/csharp-sdk/inferred-auth-explicit/src/SeedInferredAuthExplicit.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedInferredAuthExplicit.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/inferred-auth-explicit/src/SeedInferredAuthExplicit/Core/JsonConfiguration.cs b/seed/csharp-sdk/inferred-auth-explicit/src/SeedInferredAuthExplicit/Core/JsonConfiguration.cs index b6a1443adbd7..3cb1c614efbf 100644 --- a/seed/csharp-sdk/inferred-auth-explicit/src/SeedInferredAuthExplicit/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/inferred-auth-explicit/src/SeedInferredAuthExplicit/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/inferred-auth-explicit/src/SeedInferredAuthExplicit/Core/NullableAttribute.cs b/seed/csharp-sdk/inferred-auth-explicit/src/SeedInferredAuthExplicit/Core/NullableAttribute.cs new file mode 100644 index 000000000000..9a3bf49cc963 --- /dev/null +++ b/seed/csharp-sdk/inferred-auth-explicit/src/SeedInferredAuthExplicit/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedInferredAuthExplicit.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/inferred-auth-explicit/src/SeedInferredAuthExplicit/Core/Optional.cs b/seed/csharp-sdk/inferred-auth-explicit/src/SeedInferredAuthExplicit/Core/Optional.cs new file mode 100644 index 000000000000..97bf20d4e79e --- /dev/null +++ b/seed/csharp-sdk/inferred-auth-explicit/src/SeedInferredAuthExplicit/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedInferredAuthExplicit.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/inferred-auth-explicit/src/SeedInferredAuthExplicit/Core/OptionalAttribute.cs b/seed/csharp-sdk/inferred-auth-explicit/src/SeedInferredAuthExplicit/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..20ee2ec6b707 --- /dev/null +++ b/seed/csharp-sdk/inferred-auth-explicit/src/SeedInferredAuthExplicit/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedInferredAuthExplicit.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/inferred-auth-explicit/src/SeedInferredAuthExplicit/Core/RawClient.cs b/seed/csharp-sdk/inferred-auth-explicit/src/SeedInferredAuthExplicit/Core/RawClient.cs index 2541c233c8f5..69138cbab6d2 100644 --- a/seed/csharp-sdk/inferred-auth-explicit/src/SeedInferredAuthExplicit/Core/RawClient.cs +++ b/seed/csharp-sdk/inferred-auth-explicit/src/SeedInferredAuthExplicit/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/inferred-auth-implicit-api-key/src/SeedInferredAuthImplicitApiKey.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/inferred-auth-implicit-api-key/src/SeedInferredAuthImplicitApiKey.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/inferred-auth-implicit-api-key/src/SeedInferredAuthImplicitApiKey.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/inferred-auth-implicit-api-key/src/SeedInferredAuthImplicitApiKey.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/inferred-auth-implicit-api-key/src/SeedInferredAuthImplicitApiKey.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/inferred-auth-implicit-api-key/src/SeedInferredAuthImplicitApiKey.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..49cdb36cff18 --- /dev/null +++ b/seed/csharp-sdk/inferred-auth-implicit-api-key/src/SeedInferredAuthImplicitApiKey.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedInferredAuthImplicitApiKey.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/inferred-auth-implicit-api-key/src/SeedInferredAuthImplicitApiKey/Core/JsonConfiguration.cs b/seed/csharp-sdk/inferred-auth-implicit-api-key/src/SeedInferredAuthImplicitApiKey/Core/JsonConfiguration.cs index 8a773147e702..d6592307a5de 100644 --- a/seed/csharp-sdk/inferred-auth-implicit-api-key/src/SeedInferredAuthImplicitApiKey/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/inferred-auth-implicit-api-key/src/SeedInferredAuthImplicitApiKey/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/inferred-auth-implicit-api-key/src/SeedInferredAuthImplicitApiKey/Core/NullableAttribute.cs b/seed/csharp-sdk/inferred-auth-implicit-api-key/src/SeedInferredAuthImplicitApiKey/Core/NullableAttribute.cs new file mode 100644 index 000000000000..50d0725004ea --- /dev/null +++ b/seed/csharp-sdk/inferred-auth-implicit-api-key/src/SeedInferredAuthImplicitApiKey/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedInferredAuthImplicitApiKey.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/inferred-auth-implicit-api-key/src/SeedInferredAuthImplicitApiKey/Core/Optional.cs b/seed/csharp-sdk/inferred-auth-implicit-api-key/src/SeedInferredAuthImplicitApiKey/Core/Optional.cs new file mode 100644 index 000000000000..59becae535ce --- /dev/null +++ b/seed/csharp-sdk/inferred-auth-implicit-api-key/src/SeedInferredAuthImplicitApiKey/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedInferredAuthImplicitApiKey.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/inferred-auth-implicit-api-key/src/SeedInferredAuthImplicitApiKey/Core/OptionalAttribute.cs b/seed/csharp-sdk/inferred-auth-implicit-api-key/src/SeedInferredAuthImplicitApiKey/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..4ee70aba063f --- /dev/null +++ b/seed/csharp-sdk/inferred-auth-implicit-api-key/src/SeedInferredAuthImplicitApiKey/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedInferredAuthImplicitApiKey.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/inferred-auth-implicit-api-key/src/SeedInferredAuthImplicitApiKey/Core/RawClient.cs b/seed/csharp-sdk/inferred-auth-implicit-api-key/src/SeedInferredAuthImplicitApiKey/Core/RawClient.cs index 6ee8ba86c0c0..cc3fe5de1b16 100644 --- a/seed/csharp-sdk/inferred-auth-implicit-api-key/src/SeedInferredAuthImplicitApiKey/Core/RawClient.cs +++ b/seed/csharp-sdk/inferred-auth-implicit-api-key/src/SeedInferredAuthImplicitApiKey/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/inferred-auth-implicit-no-expiry/src/SeedInferredAuthImplicitNoExpiry.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/inferred-auth-implicit-no-expiry/src/SeedInferredAuthImplicitNoExpiry.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/inferred-auth-implicit-no-expiry/src/SeedInferredAuthImplicitNoExpiry.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/inferred-auth-implicit-no-expiry/src/SeedInferredAuthImplicitNoExpiry.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/inferred-auth-implicit-no-expiry/src/SeedInferredAuthImplicitNoExpiry.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/inferred-auth-implicit-no-expiry/src/SeedInferredAuthImplicitNoExpiry.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..9368cbb2a18e --- /dev/null +++ b/seed/csharp-sdk/inferred-auth-implicit-no-expiry/src/SeedInferredAuthImplicitNoExpiry.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedInferredAuthImplicitNoExpiry.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/inferred-auth-implicit-no-expiry/src/SeedInferredAuthImplicitNoExpiry/Core/JsonConfiguration.cs b/seed/csharp-sdk/inferred-auth-implicit-no-expiry/src/SeedInferredAuthImplicitNoExpiry/Core/JsonConfiguration.cs index 4189d2373947..763afbd304ca 100644 --- a/seed/csharp-sdk/inferred-auth-implicit-no-expiry/src/SeedInferredAuthImplicitNoExpiry/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/inferred-auth-implicit-no-expiry/src/SeedInferredAuthImplicitNoExpiry/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/inferred-auth-implicit-no-expiry/src/SeedInferredAuthImplicitNoExpiry/Core/NullableAttribute.cs b/seed/csharp-sdk/inferred-auth-implicit-no-expiry/src/SeedInferredAuthImplicitNoExpiry/Core/NullableAttribute.cs new file mode 100644 index 000000000000..b238596b6aec --- /dev/null +++ b/seed/csharp-sdk/inferred-auth-implicit-no-expiry/src/SeedInferredAuthImplicitNoExpiry/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedInferredAuthImplicitNoExpiry.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/inferred-auth-implicit-no-expiry/src/SeedInferredAuthImplicitNoExpiry/Core/Optional.cs b/seed/csharp-sdk/inferred-auth-implicit-no-expiry/src/SeedInferredAuthImplicitNoExpiry/Core/Optional.cs new file mode 100644 index 000000000000..d9cf41fd7c94 --- /dev/null +++ b/seed/csharp-sdk/inferred-auth-implicit-no-expiry/src/SeedInferredAuthImplicitNoExpiry/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedInferredAuthImplicitNoExpiry.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/inferred-auth-implicit-no-expiry/src/SeedInferredAuthImplicitNoExpiry/Core/OptionalAttribute.cs b/seed/csharp-sdk/inferred-auth-implicit-no-expiry/src/SeedInferredAuthImplicitNoExpiry/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..804e9b9ee4ee --- /dev/null +++ b/seed/csharp-sdk/inferred-auth-implicit-no-expiry/src/SeedInferredAuthImplicitNoExpiry/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedInferredAuthImplicitNoExpiry.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/inferred-auth-implicit-no-expiry/src/SeedInferredAuthImplicitNoExpiry/Core/RawClient.cs b/seed/csharp-sdk/inferred-auth-implicit-no-expiry/src/SeedInferredAuthImplicitNoExpiry/Core/RawClient.cs index 718a8529df5d..a151f964537e 100644 --- a/seed/csharp-sdk/inferred-auth-implicit-no-expiry/src/SeedInferredAuthImplicitNoExpiry/Core/RawClient.cs +++ b/seed/csharp-sdk/inferred-auth-implicit-no-expiry/src/SeedInferredAuthImplicitNoExpiry/Core/RawClient.cs @@ -357,6 +357,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/inferred-auth-implicit-reference/src/SeedInferredAuthImplicit.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/inferred-auth-implicit-reference/src/SeedInferredAuthImplicit.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/inferred-auth-implicit-reference/src/SeedInferredAuthImplicit.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/inferred-auth-implicit-reference/src/SeedInferredAuthImplicit.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/inferred-auth-implicit-reference/src/SeedInferredAuthImplicit.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/inferred-auth-implicit-reference/src/SeedInferredAuthImplicit.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..2fb53bdb2c5e --- /dev/null +++ b/seed/csharp-sdk/inferred-auth-implicit-reference/src/SeedInferredAuthImplicit.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedInferredAuthImplicit.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/inferred-auth-implicit-reference/src/SeedInferredAuthImplicit/Core/JsonConfiguration.cs b/seed/csharp-sdk/inferred-auth-implicit-reference/src/SeedInferredAuthImplicit/Core/JsonConfiguration.cs index deed11ff0ce1..b6e042066fee 100644 --- a/seed/csharp-sdk/inferred-auth-implicit-reference/src/SeedInferredAuthImplicit/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/inferred-auth-implicit-reference/src/SeedInferredAuthImplicit/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/inferred-auth-implicit-reference/src/SeedInferredAuthImplicit/Core/NullableAttribute.cs b/seed/csharp-sdk/inferred-auth-implicit-reference/src/SeedInferredAuthImplicit/Core/NullableAttribute.cs new file mode 100644 index 000000000000..585feaf37ab0 --- /dev/null +++ b/seed/csharp-sdk/inferred-auth-implicit-reference/src/SeedInferredAuthImplicit/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedInferredAuthImplicit.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/inferred-auth-implicit-reference/src/SeedInferredAuthImplicit/Core/Optional.cs b/seed/csharp-sdk/inferred-auth-implicit-reference/src/SeedInferredAuthImplicit/Core/Optional.cs new file mode 100644 index 000000000000..b4627989607f --- /dev/null +++ b/seed/csharp-sdk/inferred-auth-implicit-reference/src/SeedInferredAuthImplicit/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedInferredAuthImplicit.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/inferred-auth-implicit-reference/src/SeedInferredAuthImplicit/Core/OptionalAttribute.cs b/seed/csharp-sdk/inferred-auth-implicit-reference/src/SeedInferredAuthImplicit/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..accf685cb3e7 --- /dev/null +++ b/seed/csharp-sdk/inferred-auth-implicit-reference/src/SeedInferredAuthImplicit/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedInferredAuthImplicit.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/inferred-auth-implicit-reference/src/SeedInferredAuthImplicit/Core/RawClient.cs b/seed/csharp-sdk/inferred-auth-implicit-reference/src/SeedInferredAuthImplicit/Core/RawClient.cs index 974b5a71cb50..960ce001bb32 100644 --- a/seed/csharp-sdk/inferred-auth-implicit-reference/src/SeedInferredAuthImplicit/Core/RawClient.cs +++ b/seed/csharp-sdk/inferred-auth-implicit-reference/src/SeedInferredAuthImplicit/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/inferred-auth-implicit/src/SeedInferredAuthImplicit.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/inferred-auth-implicit/src/SeedInferredAuthImplicit.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/inferred-auth-implicit/src/SeedInferredAuthImplicit.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/inferred-auth-implicit/src/SeedInferredAuthImplicit.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/inferred-auth-implicit/src/SeedInferredAuthImplicit.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/inferred-auth-implicit/src/SeedInferredAuthImplicit.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..2fb53bdb2c5e --- /dev/null +++ b/seed/csharp-sdk/inferred-auth-implicit/src/SeedInferredAuthImplicit.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedInferredAuthImplicit.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/inferred-auth-implicit/src/SeedInferredAuthImplicit/Core/JsonConfiguration.cs b/seed/csharp-sdk/inferred-auth-implicit/src/SeedInferredAuthImplicit/Core/JsonConfiguration.cs index deed11ff0ce1..b6e042066fee 100644 --- a/seed/csharp-sdk/inferred-auth-implicit/src/SeedInferredAuthImplicit/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/inferred-auth-implicit/src/SeedInferredAuthImplicit/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/inferred-auth-implicit/src/SeedInferredAuthImplicit/Core/NullableAttribute.cs b/seed/csharp-sdk/inferred-auth-implicit/src/SeedInferredAuthImplicit/Core/NullableAttribute.cs new file mode 100644 index 000000000000..585feaf37ab0 --- /dev/null +++ b/seed/csharp-sdk/inferred-auth-implicit/src/SeedInferredAuthImplicit/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedInferredAuthImplicit.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/inferred-auth-implicit/src/SeedInferredAuthImplicit/Core/Optional.cs b/seed/csharp-sdk/inferred-auth-implicit/src/SeedInferredAuthImplicit/Core/Optional.cs new file mode 100644 index 000000000000..b4627989607f --- /dev/null +++ b/seed/csharp-sdk/inferred-auth-implicit/src/SeedInferredAuthImplicit/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedInferredAuthImplicit.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/inferred-auth-implicit/src/SeedInferredAuthImplicit/Core/OptionalAttribute.cs b/seed/csharp-sdk/inferred-auth-implicit/src/SeedInferredAuthImplicit/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..accf685cb3e7 --- /dev/null +++ b/seed/csharp-sdk/inferred-auth-implicit/src/SeedInferredAuthImplicit/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedInferredAuthImplicit.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/inferred-auth-implicit/src/SeedInferredAuthImplicit/Core/RawClient.cs b/seed/csharp-sdk/inferred-auth-implicit/src/SeedInferredAuthImplicit/Core/RawClient.cs index 974b5a71cb50..960ce001bb32 100644 --- a/seed/csharp-sdk/inferred-auth-implicit/src/SeedInferredAuthImplicit/Core/RawClient.cs +++ b/seed/csharp-sdk/inferred-auth-implicit/src/SeedInferredAuthImplicit/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/license/custom-license/src/SeedLicense.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/license/custom-license/src/SeedLicense.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/license/custom-license/src/SeedLicense.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/license/custom-license/src/SeedLicense.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/license/custom-license/src/SeedLicense.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/license/custom-license/src/SeedLicense.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..a3cac924e13d --- /dev/null +++ b/seed/csharp-sdk/license/custom-license/src/SeedLicense.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedLicense.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/license/custom-license/src/SeedLicense/Core/JsonConfiguration.cs b/seed/csharp-sdk/license/custom-license/src/SeedLicense/Core/JsonConfiguration.cs index c6aeeb7a6a65..ed4974c32c32 100644 --- a/seed/csharp-sdk/license/custom-license/src/SeedLicense/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/license/custom-license/src/SeedLicense/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/license/custom-license/src/SeedLicense/Core/NullableAttribute.cs b/seed/csharp-sdk/license/custom-license/src/SeedLicense/Core/NullableAttribute.cs new file mode 100644 index 000000000000..0e03c6b33ab4 --- /dev/null +++ b/seed/csharp-sdk/license/custom-license/src/SeedLicense/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedLicense.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/license/custom-license/src/SeedLicense/Core/Optional.cs b/seed/csharp-sdk/license/custom-license/src/SeedLicense/Core/Optional.cs new file mode 100644 index 000000000000..92e0ec8ac05e --- /dev/null +++ b/seed/csharp-sdk/license/custom-license/src/SeedLicense/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedLicense.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/license/custom-license/src/SeedLicense/Core/OptionalAttribute.cs b/seed/csharp-sdk/license/custom-license/src/SeedLicense/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..3892b008a0ea --- /dev/null +++ b/seed/csharp-sdk/license/custom-license/src/SeedLicense/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedLicense.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/license/custom-license/src/SeedLicense/Core/RawClient.cs b/seed/csharp-sdk/license/custom-license/src/SeedLicense/Core/RawClient.cs index e6399947a3b0..58bd6d13cdd7 100644 --- a/seed/csharp-sdk/license/custom-license/src/SeedLicense/Core/RawClient.cs +++ b/seed/csharp-sdk/license/custom-license/src/SeedLicense/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/license/mit-license/src/SeedLicense.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/license/mit-license/src/SeedLicense.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/license/mit-license/src/SeedLicense.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/license/mit-license/src/SeedLicense.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/license/mit-license/src/SeedLicense.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/license/mit-license/src/SeedLicense.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..a3cac924e13d --- /dev/null +++ b/seed/csharp-sdk/license/mit-license/src/SeedLicense.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedLicense.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/license/mit-license/src/SeedLicense/Core/JsonConfiguration.cs b/seed/csharp-sdk/license/mit-license/src/SeedLicense/Core/JsonConfiguration.cs index c6aeeb7a6a65..ed4974c32c32 100644 --- a/seed/csharp-sdk/license/mit-license/src/SeedLicense/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/license/mit-license/src/SeedLicense/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/license/mit-license/src/SeedLicense/Core/NullableAttribute.cs b/seed/csharp-sdk/license/mit-license/src/SeedLicense/Core/NullableAttribute.cs new file mode 100644 index 000000000000..0e03c6b33ab4 --- /dev/null +++ b/seed/csharp-sdk/license/mit-license/src/SeedLicense/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedLicense.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/license/mit-license/src/SeedLicense/Core/Optional.cs b/seed/csharp-sdk/license/mit-license/src/SeedLicense/Core/Optional.cs new file mode 100644 index 000000000000..92e0ec8ac05e --- /dev/null +++ b/seed/csharp-sdk/license/mit-license/src/SeedLicense/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedLicense.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/license/mit-license/src/SeedLicense/Core/OptionalAttribute.cs b/seed/csharp-sdk/license/mit-license/src/SeedLicense/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..3892b008a0ea --- /dev/null +++ b/seed/csharp-sdk/license/mit-license/src/SeedLicense/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedLicense.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/license/mit-license/src/SeedLicense/Core/RawClient.cs b/seed/csharp-sdk/license/mit-license/src/SeedLicense/Core/RawClient.cs index e6399947a3b0..58bd6d13cdd7 100644 --- a/seed/csharp-sdk/license/mit-license/src/SeedLicense/Core/RawClient.cs +++ b/seed/csharp-sdk/license/mit-license/src/SeedLicense/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/literal/no-custom-config/src/SeedLiteral.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/literal/no-custom-config/src/SeedLiteral.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/literal/no-custom-config/src/SeedLiteral.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/literal/no-custom-config/src/SeedLiteral.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/literal/no-custom-config/src/SeedLiteral.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/literal/no-custom-config/src/SeedLiteral.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..8cdafb063eb7 --- /dev/null +++ b/seed/csharp-sdk/literal/no-custom-config/src/SeedLiteral.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedLiteral.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/literal/no-custom-config/src/SeedLiteral/Core/JsonConfiguration.cs b/seed/csharp-sdk/literal/no-custom-config/src/SeedLiteral/Core/JsonConfiguration.cs index 24655ee816a3..bbc575c491db 100644 --- a/seed/csharp-sdk/literal/no-custom-config/src/SeedLiteral/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/literal/no-custom-config/src/SeedLiteral/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/literal/no-custom-config/src/SeedLiteral/Core/NullableAttribute.cs b/seed/csharp-sdk/literal/no-custom-config/src/SeedLiteral/Core/NullableAttribute.cs new file mode 100644 index 000000000000..6281548199ff --- /dev/null +++ b/seed/csharp-sdk/literal/no-custom-config/src/SeedLiteral/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedLiteral.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/literal/no-custom-config/src/SeedLiteral/Core/Optional.cs b/seed/csharp-sdk/literal/no-custom-config/src/SeedLiteral/Core/Optional.cs new file mode 100644 index 000000000000..88044271cc06 --- /dev/null +++ b/seed/csharp-sdk/literal/no-custom-config/src/SeedLiteral/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedLiteral.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/literal/no-custom-config/src/SeedLiteral/Core/OptionalAttribute.cs b/seed/csharp-sdk/literal/no-custom-config/src/SeedLiteral/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..639926f3531c --- /dev/null +++ b/seed/csharp-sdk/literal/no-custom-config/src/SeedLiteral/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedLiteral.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/literal/no-custom-config/src/SeedLiteral/Core/RawClient.cs b/seed/csharp-sdk/literal/no-custom-config/src/SeedLiteral/Core/RawClient.cs index d58c56d7fc85..dfb48f797d36 100644 --- a/seed/csharp-sdk/literal/no-custom-config/src/SeedLiteral/Core/RawClient.cs +++ b/seed/csharp-sdk/literal/no-custom-config/src/SeedLiteral/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/literal/readonly-constants/src/SeedLiteral.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/literal/readonly-constants/src/SeedLiteral.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/literal/readonly-constants/src/SeedLiteral.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/literal/readonly-constants/src/SeedLiteral.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/literal/readonly-constants/src/SeedLiteral.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/literal/readonly-constants/src/SeedLiteral.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..8cdafb063eb7 --- /dev/null +++ b/seed/csharp-sdk/literal/readonly-constants/src/SeedLiteral.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedLiteral.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/literal/readonly-constants/src/SeedLiteral/Core/JsonConfiguration.cs b/seed/csharp-sdk/literal/readonly-constants/src/SeedLiteral/Core/JsonConfiguration.cs index 24655ee816a3..bbc575c491db 100644 --- a/seed/csharp-sdk/literal/readonly-constants/src/SeedLiteral/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/literal/readonly-constants/src/SeedLiteral/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/literal/readonly-constants/src/SeedLiteral/Core/NullableAttribute.cs b/seed/csharp-sdk/literal/readonly-constants/src/SeedLiteral/Core/NullableAttribute.cs new file mode 100644 index 000000000000..6281548199ff --- /dev/null +++ b/seed/csharp-sdk/literal/readonly-constants/src/SeedLiteral/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedLiteral.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/literal/readonly-constants/src/SeedLiteral/Core/Optional.cs b/seed/csharp-sdk/literal/readonly-constants/src/SeedLiteral/Core/Optional.cs new file mode 100644 index 000000000000..88044271cc06 --- /dev/null +++ b/seed/csharp-sdk/literal/readonly-constants/src/SeedLiteral/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedLiteral.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/literal/readonly-constants/src/SeedLiteral/Core/OptionalAttribute.cs b/seed/csharp-sdk/literal/readonly-constants/src/SeedLiteral/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..639926f3531c --- /dev/null +++ b/seed/csharp-sdk/literal/readonly-constants/src/SeedLiteral/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedLiteral.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/literal/readonly-constants/src/SeedLiteral/Core/RawClient.cs b/seed/csharp-sdk/literal/readonly-constants/src/SeedLiteral/Core/RawClient.cs index d58c56d7fc85..dfb48f797d36 100644 --- a/seed/csharp-sdk/literal/readonly-constants/src/SeedLiteral/Core/RawClient.cs +++ b/seed/csharp-sdk/literal/readonly-constants/src/SeedLiteral/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/literals-unions/src/SeedLiteralsUnions.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/literals-unions/src/SeedLiteralsUnions.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/literals-unions/src/SeedLiteralsUnions.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/literals-unions/src/SeedLiteralsUnions.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/literals-unions/src/SeedLiteralsUnions.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/literals-unions/src/SeedLiteralsUnions.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..7f87567c4920 --- /dev/null +++ b/seed/csharp-sdk/literals-unions/src/SeedLiteralsUnions.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedLiteralsUnions.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/literals-unions/src/SeedLiteralsUnions/Core/JsonConfiguration.cs b/seed/csharp-sdk/literals-unions/src/SeedLiteralsUnions/Core/JsonConfiguration.cs index b7c9a3bfd13c..1c93292eac5b 100644 --- a/seed/csharp-sdk/literals-unions/src/SeedLiteralsUnions/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/literals-unions/src/SeedLiteralsUnions/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/literals-unions/src/SeedLiteralsUnions/Core/NullableAttribute.cs b/seed/csharp-sdk/literals-unions/src/SeedLiteralsUnions/Core/NullableAttribute.cs new file mode 100644 index 000000000000..9d07457e38c4 --- /dev/null +++ b/seed/csharp-sdk/literals-unions/src/SeedLiteralsUnions/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedLiteralsUnions.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/literals-unions/src/SeedLiteralsUnions/Core/Optional.cs b/seed/csharp-sdk/literals-unions/src/SeedLiteralsUnions/Core/Optional.cs new file mode 100644 index 000000000000..f033848639b3 --- /dev/null +++ b/seed/csharp-sdk/literals-unions/src/SeedLiteralsUnions/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedLiteralsUnions.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/literals-unions/src/SeedLiteralsUnions/Core/OptionalAttribute.cs b/seed/csharp-sdk/literals-unions/src/SeedLiteralsUnions/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..7962235dcdf0 --- /dev/null +++ b/seed/csharp-sdk/literals-unions/src/SeedLiteralsUnions/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedLiteralsUnions.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/literals-unions/src/SeedLiteralsUnions/Core/RawClient.cs b/seed/csharp-sdk/literals-unions/src/SeedLiteralsUnions/Core/RawClient.cs index e2bb3fe20572..12434cef1f3d 100644 --- a/seed/csharp-sdk/literals-unions/src/SeedLiteralsUnions/Core/RawClient.cs +++ b/seed/csharp-sdk/literals-unions/src/SeedLiteralsUnions/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/mixed-case/src/SeedMixedCase.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/mixed-case/src/SeedMixedCase.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/mixed-case/src/SeedMixedCase.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/mixed-case/src/SeedMixedCase.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/mixed-case/src/SeedMixedCase.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/mixed-case/src/SeedMixedCase.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..a727ad3c8e21 --- /dev/null +++ b/seed/csharp-sdk/mixed-case/src/SeedMixedCase.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedMixedCase.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/mixed-case/src/SeedMixedCase/Core/JsonConfiguration.cs b/seed/csharp-sdk/mixed-case/src/SeedMixedCase/Core/JsonConfiguration.cs index 7a4abaeee8b7..0561f701c190 100644 --- a/seed/csharp-sdk/mixed-case/src/SeedMixedCase/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/mixed-case/src/SeedMixedCase/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/mixed-case/src/SeedMixedCase/Core/NullableAttribute.cs b/seed/csharp-sdk/mixed-case/src/SeedMixedCase/Core/NullableAttribute.cs new file mode 100644 index 000000000000..1edb60e4568d --- /dev/null +++ b/seed/csharp-sdk/mixed-case/src/SeedMixedCase/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedMixedCase.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/mixed-case/src/SeedMixedCase/Core/Optional.cs b/seed/csharp-sdk/mixed-case/src/SeedMixedCase/Core/Optional.cs new file mode 100644 index 000000000000..e07d1a8afbef --- /dev/null +++ b/seed/csharp-sdk/mixed-case/src/SeedMixedCase/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedMixedCase.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/mixed-case/src/SeedMixedCase/Core/OptionalAttribute.cs b/seed/csharp-sdk/mixed-case/src/SeedMixedCase/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..fd0b331e79d8 --- /dev/null +++ b/seed/csharp-sdk/mixed-case/src/SeedMixedCase/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedMixedCase.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/mixed-case/src/SeedMixedCase/Core/RawClient.cs b/seed/csharp-sdk/mixed-case/src/SeedMixedCase/Core/RawClient.cs index 8c1524191765..71b965fb706a 100644 --- a/seed/csharp-sdk/mixed-case/src/SeedMixedCase/Core/RawClient.cs +++ b/seed/csharp-sdk/mixed-case/src/SeedMixedCase/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/mixed-file-directory/src/SeedMixedFileDirectory.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/mixed-file-directory/src/SeedMixedFileDirectory.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/mixed-file-directory/src/SeedMixedFileDirectory.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/mixed-file-directory/src/SeedMixedFileDirectory.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/mixed-file-directory/src/SeedMixedFileDirectory.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/mixed-file-directory/src/SeedMixedFileDirectory.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..dea3aaf75b66 --- /dev/null +++ b/seed/csharp-sdk/mixed-file-directory/src/SeedMixedFileDirectory.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedMixedFileDirectory.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/mixed-file-directory/src/SeedMixedFileDirectory/Core/JsonConfiguration.cs b/seed/csharp-sdk/mixed-file-directory/src/SeedMixedFileDirectory/Core/JsonConfiguration.cs index f40119ef7f07..2e3e2cba0111 100644 --- a/seed/csharp-sdk/mixed-file-directory/src/SeedMixedFileDirectory/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/mixed-file-directory/src/SeedMixedFileDirectory/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/mixed-file-directory/src/SeedMixedFileDirectory/Core/NullableAttribute.cs b/seed/csharp-sdk/mixed-file-directory/src/SeedMixedFileDirectory/Core/NullableAttribute.cs new file mode 100644 index 000000000000..5ae172a03e4e --- /dev/null +++ b/seed/csharp-sdk/mixed-file-directory/src/SeedMixedFileDirectory/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedMixedFileDirectory.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/mixed-file-directory/src/SeedMixedFileDirectory/Core/Optional.cs b/seed/csharp-sdk/mixed-file-directory/src/SeedMixedFileDirectory/Core/Optional.cs new file mode 100644 index 000000000000..22a80b4f28da --- /dev/null +++ b/seed/csharp-sdk/mixed-file-directory/src/SeedMixedFileDirectory/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedMixedFileDirectory.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/mixed-file-directory/src/SeedMixedFileDirectory/Core/OptionalAttribute.cs b/seed/csharp-sdk/mixed-file-directory/src/SeedMixedFileDirectory/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..6dbff63991f5 --- /dev/null +++ b/seed/csharp-sdk/mixed-file-directory/src/SeedMixedFileDirectory/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedMixedFileDirectory.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/mixed-file-directory/src/SeedMixedFileDirectory/Core/RawClient.cs b/seed/csharp-sdk/mixed-file-directory/src/SeedMixedFileDirectory/Core/RawClient.cs index 098e73cc4d2e..d6c09618d71d 100644 --- a/seed/csharp-sdk/mixed-file-directory/src/SeedMixedFileDirectory/Core/RawClient.cs +++ b/seed/csharp-sdk/mixed-file-directory/src/SeedMixedFileDirectory/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/multi-line-docs/src/SeedMultiLineDocs.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/multi-line-docs/src/SeedMultiLineDocs.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/multi-line-docs/src/SeedMultiLineDocs.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/multi-line-docs/src/SeedMultiLineDocs.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/multi-line-docs/src/SeedMultiLineDocs.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/multi-line-docs/src/SeedMultiLineDocs.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..0b56b9abb597 --- /dev/null +++ b/seed/csharp-sdk/multi-line-docs/src/SeedMultiLineDocs.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedMultiLineDocs.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/multi-line-docs/src/SeedMultiLineDocs/Core/JsonConfiguration.cs b/seed/csharp-sdk/multi-line-docs/src/SeedMultiLineDocs/Core/JsonConfiguration.cs index 899e3a716334..7a32b072e0bb 100644 --- a/seed/csharp-sdk/multi-line-docs/src/SeedMultiLineDocs/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/multi-line-docs/src/SeedMultiLineDocs/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/multi-line-docs/src/SeedMultiLineDocs/Core/NullableAttribute.cs b/seed/csharp-sdk/multi-line-docs/src/SeedMultiLineDocs/Core/NullableAttribute.cs new file mode 100644 index 000000000000..2f92cfec26f4 --- /dev/null +++ b/seed/csharp-sdk/multi-line-docs/src/SeedMultiLineDocs/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedMultiLineDocs.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/multi-line-docs/src/SeedMultiLineDocs/Core/Optional.cs b/seed/csharp-sdk/multi-line-docs/src/SeedMultiLineDocs/Core/Optional.cs new file mode 100644 index 000000000000..7da2151735cc --- /dev/null +++ b/seed/csharp-sdk/multi-line-docs/src/SeedMultiLineDocs/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedMultiLineDocs.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/multi-line-docs/src/SeedMultiLineDocs/Core/OptionalAttribute.cs b/seed/csharp-sdk/multi-line-docs/src/SeedMultiLineDocs/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..2b031ea0a148 --- /dev/null +++ b/seed/csharp-sdk/multi-line-docs/src/SeedMultiLineDocs/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedMultiLineDocs.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/multi-line-docs/src/SeedMultiLineDocs/Core/RawClient.cs b/seed/csharp-sdk/multi-line-docs/src/SeedMultiLineDocs/Core/RawClient.cs index 609862c2ceac..21fcfbcb1f5f 100644 --- a/seed/csharp-sdk/multi-line-docs/src/SeedMultiLineDocs/Core/RawClient.cs +++ b/seed/csharp-sdk/multi-line-docs/src/SeedMultiLineDocs/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/multi-url-environment-no-default/src/SeedMultiUrlEnvironmentNoDefault.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/multi-url-environment-no-default/src/SeedMultiUrlEnvironmentNoDefault.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/multi-url-environment-no-default/src/SeedMultiUrlEnvironmentNoDefault.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/multi-url-environment-no-default/src/SeedMultiUrlEnvironmentNoDefault.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/multi-url-environment-no-default/src/SeedMultiUrlEnvironmentNoDefault.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/multi-url-environment-no-default/src/SeedMultiUrlEnvironmentNoDefault.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..3905ff00c559 --- /dev/null +++ b/seed/csharp-sdk/multi-url-environment-no-default/src/SeedMultiUrlEnvironmentNoDefault.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedMultiUrlEnvironmentNoDefault.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/multi-url-environment-no-default/src/SeedMultiUrlEnvironmentNoDefault/Core/JsonConfiguration.cs b/seed/csharp-sdk/multi-url-environment-no-default/src/SeedMultiUrlEnvironmentNoDefault/Core/JsonConfiguration.cs index b126c4e8daf0..4795e8247abb 100644 --- a/seed/csharp-sdk/multi-url-environment-no-default/src/SeedMultiUrlEnvironmentNoDefault/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/multi-url-environment-no-default/src/SeedMultiUrlEnvironmentNoDefault/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/multi-url-environment-no-default/src/SeedMultiUrlEnvironmentNoDefault/Core/NullableAttribute.cs b/seed/csharp-sdk/multi-url-environment-no-default/src/SeedMultiUrlEnvironmentNoDefault/Core/NullableAttribute.cs new file mode 100644 index 000000000000..486feb685f1a --- /dev/null +++ b/seed/csharp-sdk/multi-url-environment-no-default/src/SeedMultiUrlEnvironmentNoDefault/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedMultiUrlEnvironmentNoDefault.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/multi-url-environment-no-default/src/SeedMultiUrlEnvironmentNoDefault/Core/Optional.cs b/seed/csharp-sdk/multi-url-environment-no-default/src/SeedMultiUrlEnvironmentNoDefault/Core/Optional.cs new file mode 100644 index 000000000000..218239c42833 --- /dev/null +++ b/seed/csharp-sdk/multi-url-environment-no-default/src/SeedMultiUrlEnvironmentNoDefault/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedMultiUrlEnvironmentNoDefault.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/multi-url-environment-no-default/src/SeedMultiUrlEnvironmentNoDefault/Core/OptionalAttribute.cs b/seed/csharp-sdk/multi-url-environment-no-default/src/SeedMultiUrlEnvironmentNoDefault/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..c1c7a2990797 --- /dev/null +++ b/seed/csharp-sdk/multi-url-environment-no-default/src/SeedMultiUrlEnvironmentNoDefault/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedMultiUrlEnvironmentNoDefault.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/multi-url-environment-no-default/src/SeedMultiUrlEnvironmentNoDefault/Core/RawClient.cs b/seed/csharp-sdk/multi-url-environment-no-default/src/SeedMultiUrlEnvironmentNoDefault/Core/RawClient.cs index a12975a5a2da..d26069c4f699 100644 --- a/seed/csharp-sdk/multi-url-environment-no-default/src/SeedMultiUrlEnvironmentNoDefault/Core/RawClient.cs +++ b/seed/csharp-sdk/multi-url-environment-no-default/src/SeedMultiUrlEnvironmentNoDefault/Core/RawClient.cs @@ -357,6 +357,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/multi-url-environment/environment-class-name/src/SeedMultiUrlEnvironment.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/multi-url-environment/environment-class-name/src/SeedMultiUrlEnvironment.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/multi-url-environment/environment-class-name/src/SeedMultiUrlEnvironment.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/multi-url-environment/environment-class-name/src/SeedMultiUrlEnvironment.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/multi-url-environment/environment-class-name/src/SeedMultiUrlEnvironment.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/multi-url-environment/environment-class-name/src/SeedMultiUrlEnvironment.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..b6ef7453448f --- /dev/null +++ b/seed/csharp-sdk/multi-url-environment/environment-class-name/src/SeedMultiUrlEnvironment.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedMultiUrlEnvironment.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/multi-url-environment/environment-class-name/src/SeedMultiUrlEnvironment/Core/JsonConfiguration.cs b/seed/csharp-sdk/multi-url-environment/environment-class-name/src/SeedMultiUrlEnvironment/Core/JsonConfiguration.cs index 6d3c50b52844..993c6a84a429 100644 --- a/seed/csharp-sdk/multi-url-environment/environment-class-name/src/SeedMultiUrlEnvironment/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/multi-url-environment/environment-class-name/src/SeedMultiUrlEnvironment/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/multi-url-environment/environment-class-name/src/SeedMultiUrlEnvironment/Core/NullableAttribute.cs b/seed/csharp-sdk/multi-url-environment/environment-class-name/src/SeedMultiUrlEnvironment/Core/NullableAttribute.cs new file mode 100644 index 000000000000..0f6abfeb436e --- /dev/null +++ b/seed/csharp-sdk/multi-url-environment/environment-class-name/src/SeedMultiUrlEnvironment/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedMultiUrlEnvironment.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/multi-url-environment/environment-class-name/src/SeedMultiUrlEnvironment/Core/Optional.cs b/seed/csharp-sdk/multi-url-environment/environment-class-name/src/SeedMultiUrlEnvironment/Core/Optional.cs new file mode 100644 index 000000000000..30710931559e --- /dev/null +++ b/seed/csharp-sdk/multi-url-environment/environment-class-name/src/SeedMultiUrlEnvironment/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedMultiUrlEnvironment.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/multi-url-environment/environment-class-name/src/SeedMultiUrlEnvironment/Core/OptionalAttribute.cs b/seed/csharp-sdk/multi-url-environment/environment-class-name/src/SeedMultiUrlEnvironment/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..ba813e941317 --- /dev/null +++ b/seed/csharp-sdk/multi-url-environment/environment-class-name/src/SeedMultiUrlEnvironment/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedMultiUrlEnvironment.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/multi-url-environment/environment-class-name/src/SeedMultiUrlEnvironment/Core/RawClient.cs b/seed/csharp-sdk/multi-url-environment/environment-class-name/src/SeedMultiUrlEnvironment/Core/RawClient.cs index b02ca574f3c0..e0951a0a69ba 100644 --- a/seed/csharp-sdk/multi-url-environment/environment-class-name/src/SeedMultiUrlEnvironment/Core/RawClient.cs +++ b/seed/csharp-sdk/multi-url-environment/environment-class-name/src/SeedMultiUrlEnvironment/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/multi-url-environment/no-pascal-case-environments/src/SeedMultiUrlEnvironment.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/multi-url-environment/no-pascal-case-environments/src/SeedMultiUrlEnvironment.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/multi-url-environment/no-pascal-case-environments/src/SeedMultiUrlEnvironment.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/multi-url-environment/no-pascal-case-environments/src/SeedMultiUrlEnvironment.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/multi-url-environment/no-pascal-case-environments/src/SeedMultiUrlEnvironment.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/multi-url-environment/no-pascal-case-environments/src/SeedMultiUrlEnvironment.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..b6ef7453448f --- /dev/null +++ b/seed/csharp-sdk/multi-url-environment/no-pascal-case-environments/src/SeedMultiUrlEnvironment.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedMultiUrlEnvironment.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/multi-url-environment/no-pascal-case-environments/src/SeedMultiUrlEnvironment/Core/JsonConfiguration.cs b/seed/csharp-sdk/multi-url-environment/no-pascal-case-environments/src/SeedMultiUrlEnvironment/Core/JsonConfiguration.cs index 6d3c50b52844..993c6a84a429 100644 --- a/seed/csharp-sdk/multi-url-environment/no-pascal-case-environments/src/SeedMultiUrlEnvironment/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/multi-url-environment/no-pascal-case-environments/src/SeedMultiUrlEnvironment/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/multi-url-environment/no-pascal-case-environments/src/SeedMultiUrlEnvironment/Core/NullableAttribute.cs b/seed/csharp-sdk/multi-url-environment/no-pascal-case-environments/src/SeedMultiUrlEnvironment/Core/NullableAttribute.cs new file mode 100644 index 000000000000..0f6abfeb436e --- /dev/null +++ b/seed/csharp-sdk/multi-url-environment/no-pascal-case-environments/src/SeedMultiUrlEnvironment/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedMultiUrlEnvironment.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/multi-url-environment/no-pascal-case-environments/src/SeedMultiUrlEnvironment/Core/Optional.cs b/seed/csharp-sdk/multi-url-environment/no-pascal-case-environments/src/SeedMultiUrlEnvironment/Core/Optional.cs new file mode 100644 index 000000000000..30710931559e --- /dev/null +++ b/seed/csharp-sdk/multi-url-environment/no-pascal-case-environments/src/SeedMultiUrlEnvironment/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedMultiUrlEnvironment.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/multi-url-environment/no-pascal-case-environments/src/SeedMultiUrlEnvironment/Core/OptionalAttribute.cs b/seed/csharp-sdk/multi-url-environment/no-pascal-case-environments/src/SeedMultiUrlEnvironment/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..ba813e941317 --- /dev/null +++ b/seed/csharp-sdk/multi-url-environment/no-pascal-case-environments/src/SeedMultiUrlEnvironment/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedMultiUrlEnvironment.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/multi-url-environment/no-pascal-case-environments/src/SeedMultiUrlEnvironment/Core/RawClient.cs b/seed/csharp-sdk/multi-url-environment/no-pascal-case-environments/src/SeedMultiUrlEnvironment/Core/RawClient.cs index b02ca574f3c0..e0951a0a69ba 100644 --- a/seed/csharp-sdk/multi-url-environment/no-pascal-case-environments/src/SeedMultiUrlEnvironment/Core/RawClient.cs +++ b/seed/csharp-sdk/multi-url-environment/no-pascal-case-environments/src/SeedMultiUrlEnvironment/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/multiple-request-bodies/src/SeedApi.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/multiple-request-bodies/src/SeedApi.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/multiple-request-bodies/src/SeedApi.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/multiple-request-bodies/src/SeedApi.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/multiple-request-bodies/src/SeedApi.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/multiple-request-bodies/src/SeedApi.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..dc732084c15f --- /dev/null +++ b/seed/csharp-sdk/multiple-request-bodies/src/SeedApi.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedApi.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/multiple-request-bodies/src/SeedApi/Core/JsonConfiguration.cs b/seed/csharp-sdk/multiple-request-bodies/src/SeedApi/Core/JsonConfiguration.cs index 321193691456..df6b2c46945d 100644 --- a/seed/csharp-sdk/multiple-request-bodies/src/SeedApi/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/multiple-request-bodies/src/SeedApi/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/multiple-request-bodies/src/SeedApi/Core/NullableAttribute.cs b/seed/csharp-sdk/multiple-request-bodies/src/SeedApi/Core/NullableAttribute.cs new file mode 100644 index 000000000000..8fca30535a2a --- /dev/null +++ b/seed/csharp-sdk/multiple-request-bodies/src/SeedApi/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedApi.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/multiple-request-bodies/src/SeedApi/Core/Optional.cs b/seed/csharp-sdk/multiple-request-bodies/src/SeedApi/Core/Optional.cs new file mode 100644 index 000000000000..4a9691feca1f --- /dev/null +++ b/seed/csharp-sdk/multiple-request-bodies/src/SeedApi/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedApi.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/multiple-request-bodies/src/SeedApi/Core/OptionalAttribute.cs b/seed/csharp-sdk/multiple-request-bodies/src/SeedApi/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..b2bc05f7244f --- /dev/null +++ b/seed/csharp-sdk/multiple-request-bodies/src/SeedApi/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedApi.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/multiple-request-bodies/src/SeedApi/Core/RawClient.cs b/seed/csharp-sdk/multiple-request-bodies/src/SeedApi/Core/RawClient.cs index 72e34bbc0e89..b60968e4eb03 100644 --- a/seed/csharp-sdk/multiple-request-bodies/src/SeedApi/Core/RawClient.cs +++ b/seed/csharp-sdk/multiple-request-bodies/src/SeedApi/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/no-environment/src/SeedNoEnvironment.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/no-environment/src/SeedNoEnvironment.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/no-environment/src/SeedNoEnvironment.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/no-environment/src/SeedNoEnvironment.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/no-environment/src/SeedNoEnvironment.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/no-environment/src/SeedNoEnvironment.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..6e8ab2f90431 --- /dev/null +++ b/seed/csharp-sdk/no-environment/src/SeedNoEnvironment.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedNoEnvironment.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/no-environment/src/SeedNoEnvironment/Core/JsonConfiguration.cs b/seed/csharp-sdk/no-environment/src/SeedNoEnvironment/Core/JsonConfiguration.cs index d3597834eba3..b4ece0c994f7 100644 --- a/seed/csharp-sdk/no-environment/src/SeedNoEnvironment/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/no-environment/src/SeedNoEnvironment/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/no-environment/src/SeedNoEnvironment/Core/NullableAttribute.cs b/seed/csharp-sdk/no-environment/src/SeedNoEnvironment/Core/NullableAttribute.cs new file mode 100644 index 000000000000..e6f657e5e332 --- /dev/null +++ b/seed/csharp-sdk/no-environment/src/SeedNoEnvironment/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedNoEnvironment.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/no-environment/src/SeedNoEnvironment/Core/Optional.cs b/seed/csharp-sdk/no-environment/src/SeedNoEnvironment/Core/Optional.cs new file mode 100644 index 000000000000..cf8601f2df00 --- /dev/null +++ b/seed/csharp-sdk/no-environment/src/SeedNoEnvironment/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedNoEnvironment.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/no-environment/src/SeedNoEnvironment/Core/OptionalAttribute.cs b/seed/csharp-sdk/no-environment/src/SeedNoEnvironment/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..5bf9fe4252be --- /dev/null +++ b/seed/csharp-sdk/no-environment/src/SeedNoEnvironment/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedNoEnvironment.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/no-environment/src/SeedNoEnvironment/Core/RawClient.cs b/seed/csharp-sdk/no-environment/src/SeedNoEnvironment/Core/RawClient.cs index 4cafabddd470..23689a45ad6d 100644 --- a/seed/csharp-sdk/no-environment/src/SeedNoEnvironment/Core/RawClient.cs +++ b/seed/csharp-sdk/no-environment/src/SeedNoEnvironment/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/no-retries/src/SeedNoRetries.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/no-retries/src/SeedNoRetries.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/no-retries/src/SeedNoRetries.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/no-retries/src/SeedNoRetries.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/no-retries/src/SeedNoRetries.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/no-retries/src/SeedNoRetries.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..4a05558d37bd --- /dev/null +++ b/seed/csharp-sdk/no-retries/src/SeedNoRetries.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedNoRetries.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/no-retries/src/SeedNoRetries/Core/JsonConfiguration.cs b/seed/csharp-sdk/no-retries/src/SeedNoRetries/Core/JsonConfiguration.cs index e502c53f9f0e..3ad0e5d8cda2 100644 --- a/seed/csharp-sdk/no-retries/src/SeedNoRetries/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/no-retries/src/SeedNoRetries/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/no-retries/src/SeedNoRetries/Core/NullableAttribute.cs b/seed/csharp-sdk/no-retries/src/SeedNoRetries/Core/NullableAttribute.cs new file mode 100644 index 000000000000..58dd9ed9a682 --- /dev/null +++ b/seed/csharp-sdk/no-retries/src/SeedNoRetries/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedNoRetries.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/no-retries/src/SeedNoRetries/Core/Optional.cs b/seed/csharp-sdk/no-retries/src/SeedNoRetries/Core/Optional.cs new file mode 100644 index 000000000000..d355616ff7c9 --- /dev/null +++ b/seed/csharp-sdk/no-retries/src/SeedNoRetries/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedNoRetries.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/no-retries/src/SeedNoRetries/Core/OptionalAttribute.cs b/seed/csharp-sdk/no-retries/src/SeedNoRetries/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..ed9282976a8a --- /dev/null +++ b/seed/csharp-sdk/no-retries/src/SeedNoRetries/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedNoRetries.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/no-retries/src/SeedNoRetries/Core/RawClient.cs b/seed/csharp-sdk/no-retries/src/SeedNoRetries/Core/RawClient.cs index 0f5f31b00ab9..a76993b3e5d8 100644 --- a/seed/csharp-sdk/no-retries/src/SeedNoRetries/Core/RawClient.cs +++ b/seed/csharp-sdk/no-retries/src/SeedNoRetries/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/nullable-allof-extends/src/SeedApi.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/nullable-allof-extends/src/SeedApi.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/nullable-allof-extends/src/SeedApi.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/nullable-allof-extends/src/SeedApi.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/nullable-allof-extends/src/SeedApi.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/nullable-allof-extends/src/SeedApi.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..dc732084c15f --- /dev/null +++ b/seed/csharp-sdk/nullable-allof-extends/src/SeedApi.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedApi.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/nullable-allof-extends/src/SeedApi/Core/JsonConfiguration.cs b/seed/csharp-sdk/nullable-allof-extends/src/SeedApi/Core/JsonConfiguration.cs index 321193691456..df6b2c46945d 100644 --- a/seed/csharp-sdk/nullable-allof-extends/src/SeedApi/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/nullable-allof-extends/src/SeedApi/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/nullable-allof-extends/src/SeedApi/Core/NullableAttribute.cs b/seed/csharp-sdk/nullable-allof-extends/src/SeedApi/Core/NullableAttribute.cs new file mode 100644 index 000000000000..8fca30535a2a --- /dev/null +++ b/seed/csharp-sdk/nullable-allof-extends/src/SeedApi/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedApi.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/nullable-allof-extends/src/SeedApi/Core/Optional.cs b/seed/csharp-sdk/nullable-allof-extends/src/SeedApi/Core/Optional.cs new file mode 100644 index 000000000000..4a9691feca1f --- /dev/null +++ b/seed/csharp-sdk/nullable-allof-extends/src/SeedApi/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedApi.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/nullable-allof-extends/src/SeedApi/Core/OptionalAttribute.cs b/seed/csharp-sdk/nullable-allof-extends/src/SeedApi/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..b2bc05f7244f --- /dev/null +++ b/seed/csharp-sdk/nullable-allof-extends/src/SeedApi/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedApi.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/nullable-allof-extends/src/SeedApi/Core/RawClient.cs b/seed/csharp-sdk/nullable-allof-extends/src/SeedApi/Core/RawClient.cs index 72e34bbc0e89..b60968e4eb03 100644 --- a/seed/csharp-sdk/nullable-allof-extends/src/SeedApi/Core/RawClient.cs +++ b/seed/csharp-sdk/nullable-allof-extends/src/SeedApi/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/.editorconfig b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/.editorconfig new file mode 100644 index 000000000000..1e7a0adbac80 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/.editorconfig @@ -0,0 +1,35 @@ +root = true + +[*.cs] +resharper_arrange_object_creation_when_type_evident_highlighting = hint +resharper_auto_property_can_be_made_get_only_global_highlighting = hint +resharper_check_namespace_highlighting = hint +resharper_class_never_instantiated_global_highlighting = hint +resharper_class_never_instantiated_local_highlighting = hint +resharper_collection_never_updated_global_highlighting = hint +resharper_convert_type_check_pattern_to_null_check_highlighting = hint +resharper_inconsistent_naming_highlighting = hint +resharper_member_can_be_private_global_highlighting = hint +resharper_member_hides_static_from_outer_class_highlighting = hint +resharper_not_accessed_field_local_highlighting = hint +resharper_nullable_warning_suppression_is_used_highlighting = suggestion +resharper_partial_type_with_single_part_highlighting = hint +resharper_prefer_concrete_value_over_default_highlighting = none +resharper_private_field_can_be_converted_to_local_variable_highlighting = hint +resharper_property_can_be_made_init_only_global_highlighting = hint +resharper_property_can_be_made_init_only_local_highlighting = hint +resharper_redundant_name_qualifier_highlighting = none +resharper_redundant_using_directive_highlighting = hint +resharper_replace_slice_with_range_indexer_highlighting = none +resharper_unused_auto_property_accessor_global_highlighting = hint +resharper_unused_auto_property_accessor_local_highlighting = hint +resharper_unused_member_global_highlighting = hint +resharper_unused_type_global_highlighting = hint +resharper_use_string_interpolation_highlighting = hint +dotnet_diagnostic.CS1591.severity = suggestion + +[src/**/Types/*.cs] +resharper_check_namespace_highlighting = none + +[src/**/Core/Public/*.cs] +resharper_check_namespace_highlighting = none \ No newline at end of file diff --git a/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/.fern/metadata.json b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/.fern/metadata.json new file mode 100644 index 000000000000..9a812ae689b2 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/.fern/metadata.json @@ -0,0 +1,9 @@ +{ + "cliVersion": "DUMMY", + "generatorName": "fernapi/fern-csharp-sdk", + "generatorVersion": "latest", + "generatorConfig": { + "experimental-explicit-nullable-optional": true + }, + "sdkVersion": "0.0.1" +} \ No newline at end of file diff --git a/seed/csharp-sdk/nullable-optional/.github/workflows/ci.yml b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/.github/workflows/ci.yml similarity index 100% rename from seed/csharp-sdk/nullable-optional/.github/workflows/ci.yml rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/.github/workflows/ci.yml diff --git a/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/.gitignore b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/.gitignore new file mode 100644 index 000000000000..11014f2b33d7 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/.gitignore @@ -0,0 +1,484 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +## This is based on `dotnet new gitignore` and customized by Fern + +# dotenv files +.env + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +# [Rr]elease/ (Ignored by Fern) +# [Rr]eleases/ (Ignored by Fern) +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +# [Ll]og/ (Ignored by Fern) +# [Ll]ogs/ (Ignored by Fern) + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET +project.lock.json +project.fragment.lock.json +artifacts/ + +# Tye +.tye/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml +.idea + +## +## Visual studio for Mac +## + + +# globs +Makefile.in +*.userprefs +*.usertasks +config.make +config.status +aclocal.m4 +install-sh +autom4te.cache/ +*.tar.gz +tarballs/ +test-results/ + +# Mac bundle stuff +*.dmg +*.app + +# content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# Vim temporary swap files +*.swp diff --git a/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/README.md b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/README.md new file mode 100644 index 000000000000..5bdfdd3ecfa4 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/README.md @@ -0,0 +1,154 @@ +# Seed C# Library + +[![fern shield](https://img.shields.io/badge/%F0%9F%8C%BF-Built%20with%20Fern-brightgreen)](https://buildwithfern.com?utm_source=github&utm_medium=github&utm_campaign=readme&utm_source=Seed%2FC%23) +[![nuget shield](https://img.shields.io/nuget/v/SeedNullableOptional)](https://nuget.org/packages/SeedNullableOptional) + +The Seed C# library provides convenient access to the Seed APIs from C#. + +## Table of Contents + +- [Requirements](#requirements) +- [Installation](#installation) +- [Reference](#reference) +- [Usage](#usage) +- [Exception Handling](#exception-handling) +- [Advanced](#advanced) + - [Retries](#retries) + - [Timeouts](#timeouts) + - [Forward Compatible Enums](#forward-compatible-enums) +- [Contributing](#contributing) + +## Requirements + +This SDK requires: + +## Installation + +```sh +dotnet add package SeedNullableOptional +``` + +## Reference + +A full reference for this library is available [here](./reference.md). + +## Usage + +Instantiate and use the client with the following: + +```csharp +using SeedNullableOptional; + +var client = new SeedNullableOptionalClient(); +await client.NullableOptional.CreateUserAsync( + new CreateUserRequest + { + Username = "username", + Email = "email", + Phone = "phone", + Address = new Address + { + Street = "street", + City = "city", + State = "state", + ZipCode = "zipCode", + Country = "country", + BuildingId = "buildingId", + TenantId = "tenantId", + }, + } +); +``` + +## Exception Handling + +When the API returns a non-success status code (4xx or 5xx response), a subclass of the following error +will be thrown. + +```csharp +using SeedNullableOptional; + +try { + var response = await client.NullableOptional.CreateUserAsync(...); +} catch (SeedNullableOptionalApiException e) { + System.Console.WriteLine(e.Body); + System.Console.WriteLine(e.StatusCode); +} +``` + +## Advanced + +### Retries + +The SDK is instrumented with automatic retries with exponential backoff. A request will be retried as long +as the request is deemed retryable and the number of retry attempts has not grown larger than the configured +retry limit (default: 2). + +A request is deemed retryable when any of the following HTTP status codes is returned: + +- [408](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) (Timeout) +- [429](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) (Too Many Requests) +- [5XX](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500) (Internal Server Errors) + +Use the `MaxRetries` request option to configure this behavior. + +```csharp +var response = await client.NullableOptional.CreateUserAsync( + ..., + new RequestOptions { + MaxRetries: 0 // Override MaxRetries at the request level + } +); +``` + +### Timeouts + +The SDK defaults to a 30 second timeout. Use the `Timeout` option to configure this behavior. + +```csharp +var response = await client.NullableOptional.CreateUserAsync( + ..., + new RequestOptions { + Timeout: TimeSpan.FromSeconds(3) // Override timeout to 3s + } +); +``` + +### Forward Compatible Enums + +This SDK uses forward-compatible enums that can handle unknown values gracefully. + +```csharp +using SeedNullableOptional; + +// Using a built-in value +var userRole = UserRole.Admin; + +// Using a custom value +var customUserRole = UserRole.FromCustom("custom-value"); + +// Using in a switch statement +switch (userRole.Value) +{ + case UserRole.Values.Admin: + Console.WriteLine("Admin"); + break; + default: + Console.WriteLine($"Unknown value: {userRole.Value}"); + break; +} + +// Explicit casting +string userRoleString = (string)UserRole.Admin; +UserRole userRoleFromString = (UserRole)"ADMIN"; +``` + +## Contributing + +While we value open-source contributions to this SDK, this library is generated programmatically. +Additions made directly to this library would have to be moved over to our generation code, +otherwise they would be overwritten upon the next generated release. Feel free to open a PR as +a proof of concept, but know that we will not be able to merge it as-is. We suggest opening +an issue first to discuss with us! + +On the other hand, contributions to the README are always very welcome! \ No newline at end of file diff --git a/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/SeedNullableOptional.slnx b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/SeedNullableOptional.slnx new file mode 100644 index 000000000000..a758ffa5f8ac --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/SeedNullableOptional.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/reference.md b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/reference.md new file mode 100644 index 000000000000..349d70933244 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/reference.md @@ -0,0 +1,1045 @@ +# Reference +## NullableOptional +
client.NullableOptional.GetUserAsync(userId) -> UserResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Get a user by ID +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```csharp +await client.NullableOptional.GetUserAsync("userId"); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**userId:** `string` + +
+
+
+
+ + +
+
+
+ +
client.NullableOptional.CreateUserAsync(CreateUserRequest { ... }) -> UserResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Create a new user +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```csharp +await client.NullableOptional.CreateUserAsync( + new CreateUserRequest + { + Username = "username", + Email = "email", + Phone = "phone", + Address = new Address + { + Street = "street", + City = "city", + State = "state", + ZipCode = "zipCode", + Country = "country", + BuildingId = "buildingId", + TenantId = "tenantId", + }, + } +); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request:** `CreateUserRequest` + +
+
+
+
+ + +
+
+
+ +
client.NullableOptional.UpdateUserAsync(userId, UpdateUserRequest { ... }) -> UserResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Update a user (partial update) +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```csharp +await client.NullableOptional.UpdateUserAsync( + "userId", + new UpdateUserRequest + { + Username = "username", + Email = "email", + Phone = "phone", + Address = new Address + { + Street = "street", + City = "city", + State = "state", + ZipCode = "zipCode", + Country = "country", + BuildingId = "buildingId", + TenantId = "tenantId", + }, + } +); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**userId:** `string` + +
+
+ +
+
+ +**request:** `UpdateUserRequest` + +
+
+
+
+ + +
+
+
+ +
client.NullableOptional.ListUsersAsync(ListUsersRequest { ... }) -> IEnumerable<UserResponse> +
+
+ +#### 📝 Description + +
+
+ +
+
+ +List all users +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```csharp +await client.NullableOptional.ListUsersAsync( + new ListUsersRequest + { + Limit = 1, + Offset = 1, + IncludeDeleted = true, + SortBy = "sortBy", + } +); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request:** `ListUsersRequest` + +
+
+
+
+ + +
+
+
+ +
client.NullableOptional.SearchUsersAsync(SearchUsersRequest { ... }) -> IEnumerable<UserResponse> +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Search users +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```csharp +await client.NullableOptional.SearchUsersAsync( + new SearchUsersRequest + { + Query = "query", + Department = "department", + Role = "role", + IsActive = true, + } +); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request:** `SearchUsersRequest` + +
+
+
+
+ + +
+
+
+ +
client.NullableOptional.CreateComplexProfileAsync(ComplexProfile { ... }) -> ComplexProfile +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Create a complex profile to test nullable enums and unions +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```csharp +await client.NullableOptional.CreateComplexProfileAsync( + new ComplexProfile + { + Id = "id", + NullableRole = UserRole.Admin, + OptionalRole = UserRole.Admin, + OptionalNullableRole = UserRole.Admin, + NullableStatus = UserStatus.Active, + OptionalStatus = UserStatus.Active, + OptionalNullableStatus = UserStatus.Active, + NullableNotification = new NotificationMethod( + new NotificationMethod.Email( + new EmailNotification + { + EmailAddress = "emailAddress", + Subject = "subject", + HtmlContent = "htmlContent", + } + ) + ), + OptionalNotification = new NotificationMethod( + new NotificationMethod.Email( + new EmailNotification + { + EmailAddress = "emailAddress", + Subject = "subject", + HtmlContent = "htmlContent", + } + ) + ), + OptionalNullableNotification = new NotificationMethod( + new NotificationMethod.Email( + new EmailNotification + { + EmailAddress = "emailAddress", + Subject = "subject", + HtmlContent = "htmlContent", + } + ) + ), + NullableSearchResult = new SearchResult( + new SearchResult.User( + new UserResponse + { + Id = "id", + Username = "username", + Email = "email", + Phone = "phone", + CreatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000), + UpdatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000), + Address = new Address + { + Street = "street", + City = "city", + State = "state", + ZipCode = "zipCode", + Country = "country", + BuildingId = "buildingId", + TenantId = "tenantId", + }, + } + ) + ), + OptionalSearchResult = new SearchResult( + new SearchResult.User( + new UserResponse + { + Id = "id", + Username = "username", + Email = "email", + Phone = "phone", + CreatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000), + UpdatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000), + Address = new Address + { + Street = "street", + City = "city", + State = "state", + ZipCode = "zipCode", + Country = "country", + BuildingId = "buildingId", + TenantId = "tenantId", + }, + } + ) + ), + NullableArray = new List() { "nullableArray", "nullableArray" }, + OptionalArray = new List() { "optionalArray", "optionalArray" }, + OptionalNullableArray = new List() + { + "optionalNullableArray", + "optionalNullableArray", + }, + NullableListOfNullables = new List() + { + "nullableListOfNullables", + "nullableListOfNullables", + }, + NullableMapOfNullables = new Dictionary() + { + { + "nullableMapOfNullables", + new Address + { + Street = "street", + City = "city", + State = "state", + ZipCode = "zipCode", + Country = "country", + BuildingId = "buildingId", + TenantId = "tenantId", + } + }, + }, + NullableListOfUnions = new List() + { + new NotificationMethod( + new NotificationMethod.Email( + new EmailNotification + { + EmailAddress = "emailAddress", + Subject = "subject", + HtmlContent = "htmlContent", + } + ) + ), + new NotificationMethod( + new NotificationMethod.Email( + new EmailNotification + { + EmailAddress = "emailAddress", + Subject = "subject", + HtmlContent = "htmlContent", + } + ) + ), + }, + OptionalMapOfEnums = new Dictionary() + { + { "optionalMapOfEnums", UserRole.Admin }, + }, + } +); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request:** `ComplexProfile` + +
+
+
+
+ + +
+
+
+ +
client.NullableOptional.GetComplexProfileAsync(profileId) -> ComplexProfile +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Get a complex profile by ID +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```csharp +await client.NullableOptional.GetComplexProfileAsync("profileId"); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**profileId:** `string` + +
+
+
+
+ + +
+
+
+ +
client.NullableOptional.UpdateComplexProfileAsync(profileId, UpdateComplexProfileRequest { ... }) -> ComplexProfile +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Update complex profile to test nullable field updates +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```csharp +await client.NullableOptional.UpdateComplexProfileAsync( + "profileId", + new UpdateComplexProfileRequest + { + NullableRole = UserRole.Admin, + NullableStatus = UserStatus.Active, + NullableNotification = new NotificationMethod( + new NotificationMethod.Email( + new EmailNotification + { + EmailAddress = "emailAddress", + Subject = "subject", + HtmlContent = "htmlContent", + } + ) + ), + NullableSearchResult = new SearchResult( + new SearchResult.User( + new UserResponse + { + Id = "id", + Username = "username", + Email = "email", + Phone = "phone", + CreatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000), + UpdatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000), + Address = new Address + { + Street = "street", + City = "city", + State = "state", + ZipCode = "zipCode", + Country = "country", + BuildingId = "buildingId", + TenantId = "tenantId", + }, + } + ) + ), + NullableArray = new List() { "nullableArray", "nullableArray" }, + } +); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**profileId:** `string` + +
+
+ +
+
+ +**request:** `UpdateComplexProfileRequest` + +
+
+
+
+ + +
+
+
+ +
client.NullableOptional.TestDeserializationAsync(DeserializationTestRequest { ... }) -> DeserializationTestResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Test endpoint for validating null deserialization +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```csharp +await client.NullableOptional.TestDeserializationAsync( + new DeserializationTestRequest + { + RequiredString = "requiredString", + NullableString = "nullableString", + OptionalString = "optionalString", + OptionalNullableString = "optionalNullableString", + NullableEnum = UserRole.Admin, + OptionalEnum = UserStatus.Active, + NullableUnion = new NotificationMethod( + new NotificationMethod.Email( + new EmailNotification + { + EmailAddress = "emailAddress", + Subject = "subject", + HtmlContent = "htmlContent", + } + ) + ), + OptionalUnion = new SearchResult( + new SearchResult.User( + new UserResponse + { + Id = "id", + Username = "username", + Email = "email", + Phone = "phone", + CreatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000), + UpdatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000), + Address = new Address + { + Street = "street", + City = "city", + State = "state", + ZipCode = "zipCode", + Country = "country", + BuildingId = "buildingId", + TenantId = "tenantId", + }, + } + ) + ), + NullableList = new List() { "nullableList", "nullableList" }, + NullableMap = new Dictionary() { { "nullableMap", 1 } }, + NullableObject = new Address + { + Street = "street", + City = "city", + State = "state", + ZipCode = "zipCode", + Country = "country", + BuildingId = "buildingId", + TenantId = "tenantId", + }, + OptionalObject = new Organization + { + Id = "id", + Name = "name", + Domain = "domain", + EmployeeCount = 1, + }, + } +); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request:** `DeserializationTestRequest` + +
+
+
+
+ + +
+
+
+ +
client.NullableOptional.FilterByRoleAsync(FilterByRoleRequest { ... }) -> IEnumerable<UserResponse> +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Filter users by role with nullable enum +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```csharp +await client.NullableOptional.FilterByRoleAsync( + new FilterByRoleRequest + { + Role = UserRole.Admin, + Status = UserStatus.Active, + SecondaryRole = UserRole.Admin, + } +); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request:** `FilterByRoleRequest` + +
+
+
+
+ + +
+
+
+ +
client.NullableOptional.GetNotificationSettingsAsync(userId) -> NotificationMethod? +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Get notification settings which may be null +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```csharp +await client.NullableOptional.GetNotificationSettingsAsync("userId"); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**userId:** `string` + +
+
+
+
+ + +
+
+
+ +
client.NullableOptional.UpdateTagsAsync(userId, UpdateTagsRequest { ... }) -> IEnumerable<string> +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Update tags to test array handling +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```csharp +await client.NullableOptional.UpdateTagsAsync( + "userId", + new UpdateTagsRequest + { + Tags = new List() { "tags", "tags" }, + Categories = new List() { "categories", "categories" }, + Labels = new List() { "labels", "labels" }, + } +); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**userId:** `string` + +
+
+ +
+
+ +**request:** `UpdateTagsRequest` + +
+
+
+
+ + +
+
+
+ +
client.NullableOptional.GetSearchResultsAsync(SearchRequest { ... }) -> IEnumerable<SearchResult>? +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Get search results with nullable unions +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```csharp +await client.NullableOptional.GetSearchResultsAsync( + new SearchRequest + { + Query = "query", + Filters = new Dictionary() { { "filters", "filters" } }, + IncludeTypes = new List() { "includeTypes", "includeTypes" }, + } +); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request:** `SearchRequest` + +
+
+
+
+ + +
+
+
diff --git a/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/snippet.json b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/snippet.json new file mode 100644 index 000000000000..b9ab7cd0c7e2 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/snippet.json @@ -0,0 +1,161 @@ +{ + "types": {}, + "endpoints": [ + { + "example_identifier": null, + "id": { + "path": "/api/users/{userId}", + "method": "GET", + "identifier_override": "endpoint_nullable-optional.getUser" + }, + "snippet": { + "type": "csharp", + "client": "using SeedNullableOptional;\n\nvar client = new SeedNullableOptionalClient();\nawait client.NullableOptional.GetUserAsync(\"userId\");\n" + } + }, + { + "example_identifier": null, + "id": { + "path": "/api/users", + "method": "POST", + "identifier_override": "endpoint_nullable-optional.createUser" + }, + "snippet": { + "type": "csharp", + "client": "using SeedNullableOptional;\n\nvar client = new SeedNullableOptionalClient();\nawait client.NullableOptional.CreateUserAsync(\n new CreateUserRequest\n {\n Username = \"username\",\n Email = \"email\",\n Phone = \"phone\",\n Address = new Address\n {\n Street = \"street\",\n City = \"city\",\n State = \"state\",\n ZipCode = \"zipCode\",\n Country = \"country\",\n BuildingId = \"buildingId\",\n TenantId = \"tenantId\",\n },\n }\n);\n" + } + }, + { + "example_identifier": null, + "id": { + "path": "/api/users/{userId}", + "method": "PATCH", + "identifier_override": "endpoint_nullable-optional.updateUser" + }, + "snippet": { + "type": "csharp", + "client": "using SeedNullableOptional;\n\nvar client = new SeedNullableOptionalClient();\nawait client.NullableOptional.UpdateUserAsync(\n \"userId\",\n new UpdateUserRequest\n {\n Username = \"username\",\n Email = \"email\",\n Phone = \"phone\",\n Address = new Address\n {\n Street = \"street\",\n City = \"city\",\n State = \"state\",\n ZipCode = \"zipCode\",\n Country = \"country\",\n BuildingId = \"buildingId\",\n TenantId = \"tenantId\",\n },\n }\n);\n" + } + }, + { + "example_identifier": null, + "id": { + "path": "/api/users", + "method": "GET", + "identifier_override": "endpoint_nullable-optional.listUsers" + }, + "snippet": { + "type": "csharp", + "client": "using SeedNullableOptional;\n\nvar client = new SeedNullableOptionalClient();\nawait client.NullableOptional.ListUsersAsync(\n new ListUsersRequest\n {\n Limit = 1,\n Offset = 1,\n IncludeDeleted = true,\n SortBy = \"sortBy\",\n }\n);\n" + } + }, + { + "example_identifier": null, + "id": { + "path": "/api/users/search", + "method": "GET", + "identifier_override": "endpoint_nullable-optional.searchUsers" + }, + "snippet": { + "type": "csharp", + "client": "using SeedNullableOptional;\n\nvar client = new SeedNullableOptionalClient();\nawait client.NullableOptional.SearchUsersAsync(\n new SearchUsersRequest\n {\n Query = \"query\",\n Department = \"department\",\n Role = \"role\",\n IsActive = true,\n }\n);\n" + } + }, + { + "example_identifier": null, + "id": { + "path": "/api/profiles/complex", + "method": "POST", + "identifier_override": "endpoint_nullable-optional.createComplexProfile" + }, + "snippet": { + "type": "csharp", + "client": "using SeedNullableOptional;\n\nvar client = new SeedNullableOptionalClient();\nawait client.NullableOptional.CreateComplexProfileAsync(\n new ComplexProfile\n {\n Id = \"id\",\n NullableRole = UserRole.Admin,\n OptionalRole = UserRole.Admin,\n OptionalNullableRole = UserRole.Admin,\n NullableStatus = UserStatus.Active,\n OptionalStatus = UserStatus.Active,\n OptionalNullableStatus = UserStatus.Active,\n NullableNotification = new NotificationMethod(\n new NotificationMethod.Email(\n new EmailNotification\n {\n EmailAddress = \"emailAddress\",\n Subject = \"subject\",\n HtmlContent = \"htmlContent\",\n }\n )\n ),\n OptionalNotification = new NotificationMethod(\n new NotificationMethod.Email(\n new EmailNotification\n {\n EmailAddress = \"emailAddress\",\n Subject = \"subject\",\n HtmlContent = \"htmlContent\",\n }\n )\n ),\n OptionalNullableNotification = new NotificationMethod(\n new NotificationMethod.Email(\n new EmailNotification\n {\n EmailAddress = \"emailAddress\",\n Subject = \"subject\",\n HtmlContent = \"htmlContent\",\n }\n )\n ),\n NullableSearchResult = new SearchResult(\n new SearchResult.User(\n new UserResponse\n {\n Id = \"id\",\n Username = \"username\",\n Email = \"email\",\n Phone = \"phone\",\n CreatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000),\n UpdatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000),\n Address = new Address\n {\n Street = \"street\",\n City = \"city\",\n State = \"state\",\n ZipCode = \"zipCode\",\n Country = \"country\",\n BuildingId = \"buildingId\",\n TenantId = \"tenantId\",\n },\n }\n )\n ),\n OptionalSearchResult = new SearchResult(\n new SearchResult.User(\n new UserResponse\n {\n Id = \"id\",\n Username = \"username\",\n Email = \"email\",\n Phone = \"phone\",\n CreatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000),\n UpdatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000),\n Address = new Address\n {\n Street = \"street\",\n City = \"city\",\n State = \"state\",\n ZipCode = \"zipCode\",\n Country = \"country\",\n BuildingId = \"buildingId\",\n TenantId = \"tenantId\",\n },\n }\n )\n ),\n NullableArray = new List() { \"nullableArray\", \"nullableArray\" },\n OptionalArray = new List() { \"optionalArray\", \"optionalArray\" },\n OptionalNullableArray = new List()\n {\n \"optionalNullableArray\",\n \"optionalNullableArray\",\n },\n NullableListOfNullables = new List()\n {\n \"nullableListOfNullables\",\n \"nullableListOfNullables\",\n },\n NullableMapOfNullables = new Dictionary()\n {\n {\n \"nullableMapOfNullables\",\n new Address\n {\n Street = \"street\",\n City = \"city\",\n State = \"state\",\n ZipCode = \"zipCode\",\n Country = \"country\",\n BuildingId = \"buildingId\",\n TenantId = \"tenantId\",\n }\n },\n },\n NullableListOfUnions = new List()\n {\n new NotificationMethod(\n new NotificationMethod.Email(\n new EmailNotification\n {\n EmailAddress = \"emailAddress\",\n Subject = \"subject\",\n HtmlContent = \"htmlContent\",\n }\n )\n ),\n new NotificationMethod(\n new NotificationMethod.Email(\n new EmailNotification\n {\n EmailAddress = \"emailAddress\",\n Subject = \"subject\",\n HtmlContent = \"htmlContent\",\n }\n )\n ),\n },\n OptionalMapOfEnums = new Dictionary()\n {\n { \"optionalMapOfEnums\", UserRole.Admin },\n },\n }\n);\n" + } + }, + { + "example_identifier": null, + "id": { + "path": "/api/profiles/complex/{profileId}", + "method": "GET", + "identifier_override": "endpoint_nullable-optional.getComplexProfile" + }, + "snippet": { + "type": "csharp", + "client": "using SeedNullableOptional;\n\nvar client = new SeedNullableOptionalClient();\nawait client.NullableOptional.GetComplexProfileAsync(\"profileId\");\n" + } + }, + { + "example_identifier": null, + "id": { + "path": "/api/profiles/complex/{profileId}", + "method": "PATCH", + "identifier_override": "endpoint_nullable-optional.updateComplexProfile" + }, + "snippet": { + "type": "csharp", + "client": "using SeedNullableOptional;\n\nvar client = new SeedNullableOptionalClient();\nawait client.NullableOptional.UpdateComplexProfileAsync(\n \"profileId\",\n new UpdateComplexProfileRequest\n {\n NullableRole = UserRole.Admin,\n NullableStatus = UserStatus.Active,\n NullableNotification = new NotificationMethod(\n new NotificationMethod.Email(\n new EmailNotification\n {\n EmailAddress = \"emailAddress\",\n Subject = \"subject\",\n HtmlContent = \"htmlContent\",\n }\n )\n ),\n NullableSearchResult = new SearchResult(\n new SearchResult.User(\n new UserResponse\n {\n Id = \"id\",\n Username = \"username\",\n Email = \"email\",\n Phone = \"phone\",\n CreatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000),\n UpdatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000),\n Address = new Address\n {\n Street = \"street\",\n City = \"city\",\n State = \"state\",\n ZipCode = \"zipCode\",\n Country = \"country\",\n BuildingId = \"buildingId\",\n TenantId = \"tenantId\",\n },\n }\n )\n ),\n NullableArray = new List() { \"nullableArray\", \"nullableArray\" },\n }\n);\n" + } + }, + { + "example_identifier": null, + "id": { + "path": "/api/test/deserialization", + "method": "POST", + "identifier_override": "endpoint_nullable-optional.testDeserialization" + }, + "snippet": { + "type": "csharp", + "client": "using SeedNullableOptional;\n\nvar client = new SeedNullableOptionalClient();\nawait client.NullableOptional.TestDeserializationAsync(\n new DeserializationTestRequest\n {\n RequiredString = \"requiredString\",\n NullableString = \"nullableString\",\n OptionalString = \"optionalString\",\n OptionalNullableString = \"optionalNullableString\",\n NullableEnum = UserRole.Admin,\n OptionalEnum = UserStatus.Active,\n NullableUnion = new NotificationMethod(\n new NotificationMethod.Email(\n new EmailNotification\n {\n EmailAddress = \"emailAddress\",\n Subject = \"subject\",\n HtmlContent = \"htmlContent\",\n }\n )\n ),\n OptionalUnion = new SearchResult(\n new SearchResult.User(\n new UserResponse\n {\n Id = \"id\",\n Username = \"username\",\n Email = \"email\",\n Phone = \"phone\",\n CreatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000),\n UpdatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000),\n Address = new Address\n {\n Street = \"street\",\n City = \"city\",\n State = \"state\",\n ZipCode = \"zipCode\",\n Country = \"country\",\n BuildingId = \"buildingId\",\n TenantId = \"tenantId\",\n },\n }\n )\n ),\n NullableList = new List() { \"nullableList\", \"nullableList\" },\n NullableMap = new Dictionary() { { \"nullableMap\", 1 } },\n NullableObject = new Address\n {\n Street = \"street\",\n City = \"city\",\n State = \"state\",\n ZipCode = \"zipCode\",\n Country = \"country\",\n BuildingId = \"buildingId\",\n TenantId = \"tenantId\",\n },\n OptionalObject = new Organization\n {\n Id = \"id\",\n Name = \"name\",\n Domain = \"domain\",\n EmployeeCount = 1,\n },\n }\n);\n" + } + }, + { + "example_identifier": null, + "id": { + "path": "/api/users/filter", + "method": "GET", + "identifier_override": "endpoint_nullable-optional.filterByRole" + }, + "snippet": { + "type": "csharp", + "client": "using SeedNullableOptional;\n\nvar client = new SeedNullableOptionalClient();\nawait client.NullableOptional.FilterByRoleAsync(\n new FilterByRoleRequest\n {\n Role = UserRole.Admin,\n Status = UserStatus.Active,\n SecondaryRole = UserRole.Admin,\n }\n);\n" + } + }, + { + "example_identifier": null, + "id": { + "path": "/api/users/{userId}/notifications", + "method": "GET", + "identifier_override": "endpoint_nullable-optional.getNotificationSettings" + }, + "snippet": { + "type": "csharp", + "client": "using SeedNullableOptional;\n\nvar client = new SeedNullableOptionalClient();\nawait client.NullableOptional.GetNotificationSettingsAsync(\"userId\");\n" + } + }, + { + "example_identifier": null, + "id": { + "path": "/api/users/{userId}/tags", + "method": "PUT", + "identifier_override": "endpoint_nullable-optional.updateTags" + }, + "snippet": { + "type": "csharp", + "client": "using SeedNullableOptional;\n\nvar client = new SeedNullableOptionalClient();\nawait client.NullableOptional.UpdateTagsAsync(\n \"userId\",\n new UpdateTagsRequest\n {\n Tags = new List() { \"tags\", \"tags\" },\n Categories = new List() { \"categories\", \"categories\" },\n Labels = new List() { \"labels\", \"labels\" },\n }\n);\n" + } + }, + { + "example_identifier": null, + "id": { + "path": "/api/search", + "method": "POST", + "identifier_override": "endpoint_nullable-optional.getSearchResults" + }, + "snippet": { + "type": "csharp", + "client": "using SeedNullableOptional;\n\nvar client = new SeedNullableOptionalClient();\nawait client.NullableOptional.GetSearchResultsAsync(\n new SearchRequest\n {\n Query = \"query\",\n Filters = new Dictionary() { { \"filters\", \"filters\" } },\n IncludeTypes = new List() { \"includeTypes\", \"includeTypes\" },\n }\n);\n" + } + } + ] +} \ No newline at end of file diff --git a/seed/csharp-sdk/nullable-optional/src/SeedApi.DynamicSnippets/Example0.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedApi.DynamicSnippets/Example0.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedApi.DynamicSnippets/Example0.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedApi.DynamicSnippets/Example0.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedApi.DynamicSnippets/Example1.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedApi.DynamicSnippets/Example1.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedApi.DynamicSnippets/Example1.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedApi.DynamicSnippets/Example1.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedApi.DynamicSnippets/Example10.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedApi.DynamicSnippets/Example10.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedApi.DynamicSnippets/Example10.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedApi.DynamicSnippets/Example10.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedApi.DynamicSnippets/Example11.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedApi.DynamicSnippets/Example11.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedApi.DynamicSnippets/Example11.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedApi.DynamicSnippets/Example11.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedApi.DynamicSnippets/Example12.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedApi.DynamicSnippets/Example12.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedApi.DynamicSnippets/Example12.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedApi.DynamicSnippets/Example12.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedApi.DynamicSnippets/Example2.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedApi.DynamicSnippets/Example2.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedApi.DynamicSnippets/Example2.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedApi.DynamicSnippets/Example2.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedApi.DynamicSnippets/Example3.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedApi.DynamicSnippets/Example3.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedApi.DynamicSnippets/Example3.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedApi.DynamicSnippets/Example3.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedApi.DynamicSnippets/Example4.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedApi.DynamicSnippets/Example4.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedApi.DynamicSnippets/Example4.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedApi.DynamicSnippets/Example4.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedApi.DynamicSnippets/Example5.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedApi.DynamicSnippets/Example5.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedApi.DynamicSnippets/Example5.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedApi.DynamicSnippets/Example5.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedApi.DynamicSnippets/Example6.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedApi.DynamicSnippets/Example6.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedApi.DynamicSnippets/Example6.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedApi.DynamicSnippets/Example6.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedApi.DynamicSnippets/Example7.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedApi.DynamicSnippets/Example7.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedApi.DynamicSnippets/Example7.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedApi.DynamicSnippets/Example7.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedApi.DynamicSnippets/Example8.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedApi.DynamicSnippets/Example8.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedApi.DynamicSnippets/Example8.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedApi.DynamicSnippets/Example8.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedApi.DynamicSnippets/Example9.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedApi.DynamicSnippets/Example9.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedApi.DynamicSnippets/Example9.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedApi.DynamicSnippets/Example9.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedApi.DynamicSnippets/SeedApi.DynamicSnippets.csproj b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedApi.DynamicSnippets/SeedApi.DynamicSnippets.csproj similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedApi.DynamicSnippets/SeedApi.DynamicSnippets.csproj rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedApi.DynamicSnippets/SeedApi.DynamicSnippets.csproj diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Core/Json/AdditionalPropertiesTests.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Core/Json/AdditionalPropertiesTests.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Core/Json/AdditionalPropertiesTests.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Core/Json/AdditionalPropertiesTests.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Core/Json/DateOnlyJsonTests.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Core/Json/DateOnlyJsonTests.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Core/Json/DateOnlyJsonTests.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Core/Json/DateOnlyJsonTests.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Core/Json/DateTimeJsonTests.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Core/Json/DateTimeJsonTests.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Core/Json/DateTimeJsonTests.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Core/Json/DateTimeJsonTests.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Core/Json/JsonAccessAttributeTests.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Core/Json/JsonAccessAttributeTests.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Core/Json/JsonAccessAttributeTests.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Core/Json/JsonAccessAttributeTests.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Core/Json/OneOfSerializerTests.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Core/Json/OneOfSerializerTests.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Core/Json/OneOfSerializerTests.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Core/Json/OneOfSerializerTests.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Core/Json/StringEnumSerializerTests.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Core/Json/StringEnumSerializerTests.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Core/Json/StringEnumSerializerTests.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Core/Json/StringEnumSerializerTests.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Core/QueryStringConverterTests.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Core/QueryStringConverterTests.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Core/QueryStringConverterTests.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Core/QueryStringConverterTests.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Core/RawClientTests/AdditionalHeadersTests.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Core/RawClientTests/AdditionalHeadersTests.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Core/RawClientTests/AdditionalHeadersTests.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Core/RawClientTests/AdditionalHeadersTests.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Core/RawClientTests/AdditionalParametersTests.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Core/RawClientTests/AdditionalParametersTests.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Core/RawClientTests/AdditionalParametersTests.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Core/RawClientTests/AdditionalParametersTests.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Core/RawClientTests/MultipartFormTests.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Core/RawClientTests/MultipartFormTests.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Core/RawClientTests/MultipartFormTests.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Core/RawClientTests/MultipartFormTests.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Core/RawClientTests/QueryParameterTests.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Core/RawClientTests/QueryParameterTests.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Core/RawClientTests/QueryParameterTests.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Core/RawClientTests/QueryParameterTests.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Core/RawClientTests/RetriesTests.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Core/RawClientTests/RetriesTests.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Core/RawClientTests/RetriesTests.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Core/RawClientTests/RetriesTests.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/SeedNullableOptional.Test.Custom.props b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/SeedNullableOptional.Test.Custom.props similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/SeedNullableOptional.Test.Custom.props rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/SeedNullableOptional.Test.Custom.props diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/SeedNullableOptional.Test.csproj b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/SeedNullableOptional.Test.csproj similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/SeedNullableOptional.Test.csproj rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/SeedNullableOptional.Test.csproj diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/TestClient.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/TestClient.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/TestClient.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/TestClient.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/BaseMockServerTest.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/BaseMockServerTest.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/BaseMockServerTest.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/BaseMockServerTest.cs diff --git a/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/CreateComplexProfileTest.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/CreateComplexProfileTest.cs new file mode 100644 index 000000000000..1f1034056bfd --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/CreateComplexProfileTest.cs @@ -0,0 +1,413 @@ +using System.Globalization; +using NUnit.Framework; +using SeedNullableOptional; +using SeedNullableOptional.Core; + +namespace SeedNullableOptional.Test.Unit.MockServer; + +[TestFixture] +public class CreateComplexProfileTest : BaseMockServerTest +{ + [NUnit.Framework.Test] + public async Task MockServerTest() + { + const string requestJson = """ + { + "id": "id", + "nullableRole": "ADMIN", + "optionalRole": "ADMIN", + "optionalNullableRole": "ADMIN", + "nullableStatus": "active", + "optionalStatus": "active", + "optionalNullableStatus": "active", + "nullableNotification": { + "type": "email", + "emailAddress": "emailAddress", + "subject": "subject", + "htmlContent": "htmlContent" + }, + "optionalNotification": { + "type": "email", + "emailAddress": "emailAddress", + "subject": "subject", + "htmlContent": "htmlContent" + }, + "optionalNullableNotification": { + "type": "email", + "emailAddress": "emailAddress", + "subject": "subject", + "htmlContent": "htmlContent" + }, + "nullableSearchResult": { + "type": "user", + "id": "id", + "username": "username", + "email": "email", + "phone": "phone", + "createdAt": "2024-01-15T09:30:00.000Z", + "updatedAt": "2024-01-15T09:30:00.000Z", + "address": { + "street": "street", + "city": "city", + "state": "state", + "zipCode": "zipCode", + "country": "country", + "buildingId": "buildingId", + "tenantId": "tenantId" + } + }, + "optionalSearchResult": { + "type": "user", + "id": "id", + "username": "username", + "email": "email", + "phone": "phone", + "createdAt": "2024-01-15T09:30:00.000Z", + "updatedAt": "2024-01-15T09:30:00.000Z", + "address": { + "street": "street", + "city": "city", + "state": "state", + "zipCode": "zipCode", + "country": "country", + "buildingId": "buildingId", + "tenantId": "tenantId" + } + }, + "nullableArray": [ + "nullableArray", + "nullableArray" + ], + "optionalArray": [ + "optionalArray", + "optionalArray" + ], + "optionalNullableArray": [ + "optionalNullableArray", + "optionalNullableArray" + ], + "nullableListOfNullables": [ + "nullableListOfNullables", + "nullableListOfNullables" + ], + "nullableMapOfNullables": { + "nullableMapOfNullables": { + "street": "street", + "city": "city", + "state": "state", + "zipCode": "zipCode", + "country": "country", + "buildingId": "buildingId", + "tenantId": "tenantId" + } + }, + "nullableListOfUnions": [ + { + "type": "email", + "emailAddress": "emailAddress", + "subject": "subject", + "htmlContent": "htmlContent" + }, + { + "type": "email", + "emailAddress": "emailAddress", + "subject": "subject", + "htmlContent": "htmlContent" + } + ], + "optionalMapOfEnums": { + "optionalMapOfEnums": "ADMIN" + } + } + """; + + const string mockResponse = """ + { + "id": "id", + "nullableRole": "ADMIN", + "optionalRole": "ADMIN", + "optionalNullableRole": "ADMIN", + "nullableStatus": "active", + "optionalStatus": "active", + "optionalNullableStatus": "active", + "nullableNotification": { + "type": "email", + "emailAddress": "emailAddress", + "subject": "subject", + "htmlContent": "htmlContent" + }, + "optionalNotification": { + "type": "email", + "emailAddress": "emailAddress", + "subject": "subject", + "htmlContent": "htmlContent" + }, + "optionalNullableNotification": { + "type": "email", + "emailAddress": "emailAddress", + "subject": "subject", + "htmlContent": "htmlContent" + }, + "nullableSearchResult": { + "type": "user", + "id": "id", + "username": "username", + "email": "email", + "phone": "phone", + "createdAt": "2024-01-15T09:30:00.000Z", + "updatedAt": "2024-01-15T09:30:00.000Z", + "address": { + "street": "street", + "city": "city", + "state": "state", + "zipCode": "zipCode", + "country": "country", + "buildingId": "buildingId", + "tenantId": "tenantId" + } + }, + "optionalSearchResult": { + "type": "user", + "id": "id", + "username": "username", + "email": "email", + "phone": "phone", + "createdAt": "2024-01-15T09:30:00.000Z", + "updatedAt": "2024-01-15T09:30:00.000Z", + "address": { + "street": "street", + "city": "city", + "state": "state", + "zipCode": "zipCode", + "country": "country", + "buildingId": "buildingId", + "tenantId": "tenantId" + } + }, + "nullableArray": [ + "nullableArray", + "nullableArray" + ], + "optionalArray": [ + "optionalArray", + "optionalArray" + ], + "optionalNullableArray": [ + "optionalNullableArray", + "optionalNullableArray" + ], + "nullableListOfNullables": [ + "nullableListOfNullables", + "nullableListOfNullables" + ], + "nullableMapOfNullables": { + "nullableMapOfNullables": { + "street": "street", + "city": "city", + "state": "state", + "zipCode": "zipCode", + "country": "country", + "buildingId": "buildingId", + "tenantId": "tenantId" + } + }, + "nullableListOfUnions": [ + { + "type": "email", + "emailAddress": "emailAddress", + "subject": "subject", + "htmlContent": "htmlContent" + }, + { + "type": "email", + "emailAddress": "emailAddress", + "subject": "subject", + "htmlContent": "htmlContent" + } + ], + "optionalMapOfEnums": { + "optionalMapOfEnums": "ADMIN" + } + } + """; + + Server + .Given( + WireMock + .RequestBuilders.Request.Create() + .WithPath("/api/profiles/complex") + .UsingPost() + .WithBodyAsJson(requestJson) + ) + .RespondWith( + WireMock + .ResponseBuilders.Response.Create() + .WithStatusCode(200) + .WithBody(mockResponse) + ); + + var response = await Client.NullableOptional.CreateComplexProfileAsync( + new ComplexProfile + { + Id = "id", + NullableRole = UserRole.Admin, + OptionalRole = UserRole.Admin, + OptionalNullableRole = UserRole.Admin, + NullableStatus = UserStatus.Active, + OptionalStatus = UserStatus.Active, + OptionalNullableStatus = UserStatus.Active, + NullableNotification = new NotificationMethod( + new NotificationMethod.Email( + new EmailNotification + { + EmailAddress = "emailAddress", + Subject = "subject", + HtmlContent = "htmlContent", + } + ) + ), + OptionalNotification = new NotificationMethod( + new NotificationMethod.Email( + new EmailNotification + { + EmailAddress = "emailAddress", + Subject = "subject", + HtmlContent = "htmlContent", + } + ) + ), + OptionalNullableNotification = new NotificationMethod( + new NotificationMethod.Email( + new EmailNotification + { + EmailAddress = "emailAddress", + Subject = "subject", + HtmlContent = "htmlContent", + } + ) + ), + NullableSearchResult = new SearchResult( + new SearchResult.User( + new UserResponse + { + Id = "id", + Username = "username", + Email = "email", + Phone = "phone", + CreatedAt = DateTime.Parse( + "2024-01-15T09:30:00.000Z", + null, + DateTimeStyles.AdjustToUniversal + ), + UpdatedAt = DateTime.Parse( + "2024-01-15T09:30:00.000Z", + null, + DateTimeStyles.AdjustToUniversal + ), + Address = new Address + { + Street = "street", + City = "city", + State = "state", + ZipCode = "zipCode", + Country = "country", + BuildingId = "buildingId", + TenantId = "tenantId", + }, + } + ) + ), + OptionalSearchResult = new SearchResult( + new SearchResult.User( + new UserResponse + { + Id = "id", + Username = "username", + Email = "email", + Phone = "phone", + CreatedAt = DateTime.Parse( + "2024-01-15T09:30:00.000Z", + null, + DateTimeStyles.AdjustToUniversal + ), + UpdatedAt = DateTime.Parse( + "2024-01-15T09:30:00.000Z", + null, + DateTimeStyles.AdjustToUniversal + ), + Address = new Address + { + Street = "street", + City = "city", + State = "state", + ZipCode = "zipCode", + Country = "country", + BuildingId = "buildingId", + TenantId = "tenantId", + }, + } + ) + ), + NullableArray = new List() { "nullableArray", "nullableArray" }, + OptionalArray = new List() { "optionalArray", "optionalArray" }, + OptionalNullableArray = new List() + { + "optionalNullableArray", + "optionalNullableArray", + }, + NullableListOfNullables = new List() + { + "nullableListOfNullables", + "nullableListOfNullables", + }, + NullableMapOfNullables = new Dictionary() + { + { + "nullableMapOfNullables", + new Address + { + Street = "street", + City = "city", + State = "state", + ZipCode = "zipCode", + Country = "country", + BuildingId = "buildingId", + TenantId = "tenantId", + } + }, + }, + NullableListOfUnions = new List() + { + new NotificationMethod( + new NotificationMethod.Email( + new EmailNotification + { + EmailAddress = "emailAddress", + Subject = "subject", + HtmlContent = "htmlContent", + } + ) + ), + new NotificationMethod( + new NotificationMethod.Email( + new EmailNotification + { + EmailAddress = "emailAddress", + Subject = "subject", + HtmlContent = "htmlContent", + } + ) + ), + }, + OptionalMapOfEnums = new Dictionary() + { + { "optionalMapOfEnums", UserRole.Admin }, + }, + } + ); + Assert.That( + response, + Is.EqualTo(JsonUtils.Deserialize(mockResponse)).UsingDefaults() + ); + } +} diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/CreateUserTest.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/CreateUserTest.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/CreateUserTest.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/CreateUserTest.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/FilterByRoleTest.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/FilterByRoleTest.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/FilterByRoleTest.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/FilterByRoleTest.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/GetComplexProfileTest.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/GetComplexProfileTest.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/GetComplexProfileTest.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/GetComplexProfileTest.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/GetNotificationSettingsTest.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/GetNotificationSettingsTest.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/GetNotificationSettingsTest.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/GetNotificationSettingsTest.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/GetSearchResultsTest.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/GetSearchResultsTest.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/GetSearchResultsTest.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/GetSearchResultsTest.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/GetUserTest.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/GetUserTest.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/GetUserTest.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/GetUserTest.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/ListUsersTest.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/ListUsersTest.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/ListUsersTest.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/ListUsersTest.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/SearchUsersTest.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/SearchUsersTest.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/SearchUsersTest.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/SearchUsersTest.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/TestDeserializationTest.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/TestDeserializationTest.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/TestDeserializationTest.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/TestDeserializationTest.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/UpdateComplexProfileTest.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/UpdateComplexProfileTest.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/UpdateComplexProfileTest.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/UpdateComplexProfileTest.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/UpdateTagsTest.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/UpdateTagsTest.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/UpdateTagsTest.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/UpdateTagsTest.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/UpdateUserTest.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/UpdateUserTest.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/UpdateUserTest.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/UpdateUserTest.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Utils/JsonElementComparer.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Utils/JsonElementComparer.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Utils/JsonElementComparer.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Utils/JsonElementComparer.cs diff --git a/seed/csharp-sdk/nullable/src/SeedNullable.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Utils/NUnitExtensions.cs similarity index 92% rename from seed/csharp-sdk/nullable/src/SeedNullable.Test/Utils/NUnitExtensions.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/nullable/src/SeedNullable.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Utils/OneOfComparer.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Utils/OneOfComparer.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Utils/OneOfComparer.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Utils/OneOfComparer.cs diff --git a/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..74d67adc08e9 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedNullableOptional.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Utils/ReadOnlyMemoryComparer.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Utils/ReadOnlyMemoryComparer.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Utils/ReadOnlyMemoryComparer.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional.Test/Utils/ReadOnlyMemoryComparer.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/ApiResponse.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/ApiResponse.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/ApiResponse.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/ApiResponse.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/BaseRequest.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/BaseRequest.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/BaseRequest.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/BaseRequest.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/CollectionItemSerializer.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/CollectionItemSerializer.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/CollectionItemSerializer.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/CollectionItemSerializer.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/Constants.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/Constants.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/Constants.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/Constants.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/DateOnlyConverter.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/DateOnlyConverter.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/DateOnlyConverter.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/DateOnlyConverter.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/DateTimeSerializer.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/DateTimeSerializer.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/DateTimeSerializer.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/DateTimeSerializer.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/EmptyRequest.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/EmptyRequest.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/EmptyRequest.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/EmptyRequest.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/EncodingCache.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/EncodingCache.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/EncodingCache.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/EncodingCache.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/Extensions.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/Extensions.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/Extensions.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/Extensions.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/FormUrlEncoder.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/FormUrlEncoder.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/FormUrlEncoder.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/FormUrlEncoder.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/HeaderValue.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/HeaderValue.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/HeaderValue.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/HeaderValue.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/Headers.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/Headers.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/Headers.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/Headers.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/HttpMethodExtensions.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/HttpMethodExtensions.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/HttpMethodExtensions.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/HttpMethodExtensions.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/IIsRetryableContent.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/IIsRetryableContent.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/IIsRetryableContent.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/IIsRetryableContent.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/IRequestOptions.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/IRequestOptions.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/IRequestOptions.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/IRequestOptions.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/JsonAccessAttribute.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/JsonAccessAttribute.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/JsonAccessAttribute.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/JsonAccessAttribute.cs diff --git a/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/JsonConfiguration.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/JsonConfiguration.cs new file mode 100644 index 000000000000..77c217f702a5 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/JsonConfiguration.cs @@ -0,0 +1,251 @@ +using global::System.Reflection; +using global::System.Text.Json; +using global::System.Text.Json.Nodes; +using global::System.Text.Json.Serialization; +using global::System.Text.Json.Serialization.Metadata; + +namespace SeedNullableOptional.Core; + +internal static partial class JsonOptions +{ + internal static readonly JsonSerializerOptions JsonSerializerOptions; + + static JsonOptions() + { + var options = new JsonSerializerOptions + { + Converters = + { + new DateTimeSerializer(), +#if USE_PORTABLE_DATE_ONLY + new DateOnlyConverter(), +#endif + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, +#if DEBUG + WriteIndented = true, +#endif + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = new DefaultJsonTypeInfoResolver + { + Modifiers = + { + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, + }, + }, + }; + ConfigureJsonSerializerOptions(options); + JsonSerializerOptions = options; + } + + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); +} + +internal static class JsonUtils +{ + internal static string Serialize(T obj) => + JsonSerializer.Serialize(obj, JsonOptions.JsonSerializerOptions); + + internal static JsonElement SerializeToElement(T obj) => + JsonSerializer.SerializeToElement(obj, JsonOptions.JsonSerializerOptions); + + internal static JsonDocument SerializeToDocument(T obj) => + JsonSerializer.SerializeToDocument(obj, JsonOptions.JsonSerializerOptions); + + internal static JsonNode? SerializeToNode(T obj) => + JsonSerializer.SerializeToNode(obj, JsonOptions.JsonSerializerOptions); + + internal static byte[] SerializeToUtf8Bytes(T obj) => + JsonSerializer.SerializeToUtf8Bytes(obj, JsonOptions.JsonSerializerOptions); + + internal static string SerializeWithAdditionalProperties( + T obj, + object? additionalProperties = null + ) + { + if (additionalProperties == null) + { + return Serialize(obj); + } + var additionalPropertiesJsonNode = SerializeToNode(additionalProperties); + if (additionalPropertiesJsonNode is not JsonObject additionalPropertiesJsonObject) + { + throw new InvalidOperationException( + "The additional properties must serialize to a JSON object." + ); + } + var jsonNode = SerializeToNode(obj); + if (jsonNode is not JsonObject jsonObject) + { + throw new InvalidOperationException( + "The serialized object must be a JSON object to add properties." + ); + } + MergeJsonObjects(jsonObject, additionalPropertiesJsonObject); + return jsonObject.ToJsonString(JsonOptions.JsonSerializerOptions); + } + + private static void MergeJsonObjects(JsonObject baseObject, JsonObject overrideObject) + { + foreach (var property in overrideObject) + { + if (!baseObject.TryGetPropertyValue(property.Key, out JsonNode? existingValue)) + { + baseObject[property.Key] = + property.Value != null ? JsonNode.Parse(property.Value.ToJsonString()) : null; + continue; + } + if ( + existingValue is JsonObject nestedBaseObject + && property.Value is JsonObject nestedOverrideObject + ) + { + // If both values are objects, recursively merge them. + MergeJsonObjects(nestedBaseObject, nestedOverrideObject); + continue; + } + // Otherwise, the overrideObject takes precedence. + baseObject[property.Key] = + property.Value != null ? JsonNode.Parse(property.Value.ToJsonString()) : null; + } + } + + internal static T Deserialize(string json) => + JsonSerializer.Deserialize(json, JsonOptions.JsonSerializerOptions)!; +} diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/JsonRequest.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/JsonRequest.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/JsonRequest.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/JsonRequest.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/MultipartFormRequest.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/MultipartFormRequest.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/MultipartFormRequest.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/MultipartFormRequest.cs diff --git a/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/NullableAttribute.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/NullableAttribute.cs new file mode 100644 index 000000000000..e40ce4e90658 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedNullableOptional.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/OneOfSerializer.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/OneOfSerializer.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/OneOfSerializer.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/OneOfSerializer.cs diff --git a/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/Optional.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/Optional.cs new file mode 100644 index 000000000000..d22b62b85fb9 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedNullableOptional.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/OptionalAttribute.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..3d5638427001 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedNullableOptional.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/Public/AdditionalProperties.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/Public/AdditionalProperties.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/Public/AdditionalProperties.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/Public/AdditionalProperties.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/Public/ClientOptions.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/Public/ClientOptions.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/Public/ClientOptions.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/Public/ClientOptions.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/Public/FileParameter.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/Public/FileParameter.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/Public/FileParameter.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/Public/FileParameter.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/Public/RequestOptions.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/Public/RequestOptions.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/Public/RequestOptions.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/Public/RequestOptions.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/Public/SeedNullableOptionalApiException.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/Public/SeedNullableOptionalApiException.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/Public/SeedNullableOptionalApiException.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/Public/SeedNullableOptionalApiException.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/Public/SeedNullableOptionalException.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/Public/SeedNullableOptionalException.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/Public/SeedNullableOptionalException.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/Public/SeedNullableOptionalException.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/Public/Version.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/Public/Version.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/Public/Version.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/Public/Version.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/QueryStringConverter.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/QueryStringConverter.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/QueryStringConverter.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/QueryStringConverter.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/RawClient.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/RawClient.cs similarity index 99% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/RawClient.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/RawClient.cs index 27060b1ddf2c..79943954afea 100644 --- a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/RawClient.cs +++ b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/StreamRequest.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/StreamRequest.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/StreamRequest.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/StreamRequest.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/StringEnum.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/StringEnum.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/StringEnum.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/StringEnum.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/StringEnumExtensions.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/StringEnumExtensions.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/StringEnumExtensions.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/StringEnumExtensions.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/StringEnumSerializer.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/StringEnumSerializer.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/StringEnumSerializer.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/StringEnumSerializer.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/ValueConvert.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/ValueConvert.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/ValueConvert.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/Core/ValueConvert.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/ISeedNullableOptionalClient.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/ISeedNullableOptionalClient.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/ISeedNullableOptionalClient.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/ISeedNullableOptionalClient.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/NullableOptional/INullableOptionalClient.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/INullableOptionalClient.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/NullableOptional/INullableOptionalClient.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/INullableOptionalClient.cs diff --git a/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/NullableOptionalClient.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/NullableOptionalClient.cs new file mode 100644 index 000000000000..3ff81840f405 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/NullableOptionalClient.cs @@ -0,0 +1,1012 @@ +using System.Text.Json; +using SeedNullableOptional.Core; + +namespace SeedNullableOptional; + +public partial class NullableOptionalClient : INullableOptionalClient +{ + private RawClient _client; + + internal NullableOptionalClient(RawClient client) + { + _client = client; + } + + /// + /// Get a user by ID + /// + /// + /// await client.NullableOptional.GetUserAsync("userId"); + /// + public async Task GetUserAsync( + string userId, + RequestOptions? options = null, + CancellationToken cancellationToken = default + ) + { + var response = await _client + .SendRequestAsync( + new JsonRequest + { + BaseUrl = _client.Options.BaseUrl, + Method = HttpMethod.Get, + Path = string.Format( + "/api/users/{0}", + ValueConvert.ToPathParameterString(userId) + ), + Options = options, + }, + cancellationToken + ) + .ConfigureAwait(false); + if (response.StatusCode is >= 200 and < 400) + { + var responseBody = await response.Raw.Content.ReadAsStringAsync(); + try + { + return JsonUtils.Deserialize(responseBody)!; + } + catch (JsonException e) + { + throw new SeedNullableOptionalException("Failed to deserialize response", e); + } + } + + { + var responseBody = await response.Raw.Content.ReadAsStringAsync(); + throw new SeedNullableOptionalApiException( + $"Error with status code {response.StatusCode}", + response.StatusCode, + responseBody + ); + } + } + + /// + /// Create a new user + /// + /// + /// await client.NullableOptional.CreateUserAsync( + /// new CreateUserRequest + /// { + /// Username = "username", + /// Email = "email", + /// Phone = "phone", + /// Address = new Address + /// { + /// Street = "street", + /// City = "city", + /// State = "state", + /// ZipCode = "zipCode", + /// Country = "country", + /// BuildingId = "buildingId", + /// TenantId = "tenantId", + /// }, + /// } + /// ); + /// + public async Task CreateUserAsync( + CreateUserRequest request, + RequestOptions? options = null, + CancellationToken cancellationToken = default + ) + { + var response = await _client + .SendRequestAsync( + new JsonRequest + { + BaseUrl = _client.Options.BaseUrl, + Method = HttpMethod.Post, + Path = "/api/users", + Body = request, + Options = options, + }, + cancellationToken + ) + .ConfigureAwait(false); + if (response.StatusCode is >= 200 and < 400) + { + var responseBody = await response.Raw.Content.ReadAsStringAsync(); + try + { + return JsonUtils.Deserialize(responseBody)!; + } + catch (JsonException e) + { + throw new SeedNullableOptionalException("Failed to deserialize response", e); + } + } + + { + var responseBody = await response.Raw.Content.ReadAsStringAsync(); + throw new SeedNullableOptionalApiException( + $"Error with status code {response.StatusCode}", + response.StatusCode, + responseBody + ); + } + } + + /// + /// Update a user (partial update) + /// + /// + /// await client.NullableOptional.UpdateUserAsync( + /// "userId", + /// new UpdateUserRequest + /// { + /// Username = "username", + /// Email = "email", + /// Phone = "phone", + /// Address = new Address + /// { + /// Street = "street", + /// City = "city", + /// State = "state", + /// ZipCode = "zipCode", + /// Country = "country", + /// BuildingId = "buildingId", + /// TenantId = "tenantId", + /// }, + /// } + /// ); + /// + public async Task UpdateUserAsync( + string userId, + UpdateUserRequest request, + RequestOptions? options = null, + CancellationToken cancellationToken = default + ) + { + var response = await _client + .SendRequestAsync( + new JsonRequest + { + BaseUrl = _client.Options.BaseUrl, + Method = HttpMethodExtensions.Patch, + Path = string.Format( + "/api/users/{0}", + ValueConvert.ToPathParameterString(userId) + ), + Body = request, + Options = options, + }, + cancellationToken + ) + .ConfigureAwait(false); + if (response.StatusCode is >= 200 and < 400) + { + var responseBody = await response.Raw.Content.ReadAsStringAsync(); + try + { + return JsonUtils.Deserialize(responseBody)!; + } + catch (JsonException e) + { + throw new SeedNullableOptionalException("Failed to deserialize response", e); + } + } + + { + var responseBody = await response.Raw.Content.ReadAsStringAsync(); + throw new SeedNullableOptionalApiException( + $"Error with status code {response.StatusCode}", + response.StatusCode, + responseBody + ); + } + } + + /// + /// List all users + /// + /// + /// await client.NullableOptional.ListUsersAsync( + /// new ListUsersRequest + /// { + /// Limit = 1, + /// Offset = 1, + /// IncludeDeleted = true, + /// SortBy = "sortBy", + /// } + /// ); + /// + public async Task> ListUsersAsync( + ListUsersRequest request, + RequestOptions? options = null, + CancellationToken cancellationToken = default + ) + { + var _query = new Dictionary(); + if (request.Limit != null) + { + _query["limit"] = request.Limit.ToString(); + } + if (request.Offset != null) + { + _query["offset"] = request.Offset.ToString(); + } + if (request.IncludeDeleted != null) + { + _query["includeDeleted"] = JsonUtils.Serialize(request.IncludeDeleted); + } + if (request.SortBy.IsDefined) + { + _query["sortBy"] = request.SortBy.Value; + } + var response = await _client + .SendRequestAsync( + new JsonRequest + { + BaseUrl = _client.Options.BaseUrl, + Method = HttpMethod.Get, + Path = "/api/users", + Query = _query, + Options = options, + }, + cancellationToken + ) + .ConfigureAwait(false); + if (response.StatusCode is >= 200 and < 400) + { + var responseBody = await response.Raw.Content.ReadAsStringAsync(); + try + { + return JsonUtils.Deserialize>(responseBody)!; + } + catch (JsonException e) + { + throw new SeedNullableOptionalException("Failed to deserialize response", e); + } + } + + { + var responseBody = await response.Raw.Content.ReadAsStringAsync(); + throw new SeedNullableOptionalApiException( + $"Error with status code {response.StatusCode}", + response.StatusCode, + responseBody + ); + } + } + + /// + /// Search users + /// + /// + /// await client.NullableOptional.SearchUsersAsync( + /// new SearchUsersRequest + /// { + /// Query = "query", + /// Department = "department", + /// Role = "role", + /// IsActive = true, + /// } + /// ); + /// + public async Task> SearchUsersAsync( + SearchUsersRequest request, + RequestOptions? options = null, + CancellationToken cancellationToken = default + ) + { + var _query = new Dictionary(); + _query["query"] = request.Query; + _query["department"] = request.Department; + if (request.Role != null) + { + _query["role"] = request.Role; + } + if (request.IsActive.IsDefined) + { + _query["isActive"] = JsonUtils.Serialize(request.IsActive.Value); + } + var response = await _client + .SendRequestAsync( + new JsonRequest + { + BaseUrl = _client.Options.BaseUrl, + Method = HttpMethod.Get, + Path = "/api/users/search", + Query = _query, + Options = options, + }, + cancellationToken + ) + .ConfigureAwait(false); + if (response.StatusCode is >= 200 and < 400) + { + var responseBody = await response.Raw.Content.ReadAsStringAsync(); + try + { + return JsonUtils.Deserialize>(responseBody)!; + } + catch (JsonException e) + { + throw new SeedNullableOptionalException("Failed to deserialize response", e); + } + } + + { + var responseBody = await response.Raw.Content.ReadAsStringAsync(); + throw new SeedNullableOptionalApiException( + $"Error with status code {response.StatusCode}", + response.StatusCode, + responseBody + ); + } + } + + /// + /// Create a complex profile to test nullable enums and unions + /// + /// + /// await client.NullableOptional.CreateComplexProfileAsync( + /// new ComplexProfile + /// { + /// Id = "id", + /// NullableRole = UserRole.Admin, + /// OptionalRole = UserRole.Admin, + /// OptionalNullableRole = UserRole.Admin, + /// NullableStatus = UserStatus.Active, + /// OptionalStatus = UserStatus.Active, + /// OptionalNullableStatus = UserStatus.Active, + /// NullableNotification = new NotificationMethod( + /// new NotificationMethod.Email( + /// new EmailNotification + /// { + /// EmailAddress = "emailAddress", + /// Subject = "subject", + /// HtmlContent = "htmlContent", + /// } + /// ) + /// ), + /// OptionalNotification = new NotificationMethod( + /// new NotificationMethod.Email( + /// new EmailNotification + /// { + /// EmailAddress = "emailAddress", + /// Subject = "subject", + /// HtmlContent = "htmlContent", + /// } + /// ) + /// ), + /// OptionalNullableNotification = new NotificationMethod( + /// new NotificationMethod.Email( + /// new EmailNotification + /// { + /// EmailAddress = "emailAddress", + /// Subject = "subject", + /// HtmlContent = "htmlContent", + /// } + /// ) + /// ), + /// NullableSearchResult = new SearchResult( + /// new SearchResult.User( + /// new UserResponse + /// { + /// Id = "id", + /// Username = "username", + /// Email = "email", + /// Phone = "phone", + /// CreatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000), + /// UpdatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000), + /// Address = new Address + /// { + /// Street = "street", + /// City = "city", + /// State = "state", + /// ZipCode = "zipCode", + /// Country = "country", + /// BuildingId = "buildingId", + /// TenantId = "tenantId", + /// }, + /// } + /// ) + /// ), + /// OptionalSearchResult = new SearchResult( + /// new SearchResult.User( + /// new UserResponse + /// { + /// Id = "id", + /// Username = "username", + /// Email = "email", + /// Phone = "phone", + /// CreatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000), + /// UpdatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000), + /// Address = new Address + /// { + /// Street = "street", + /// City = "city", + /// State = "state", + /// ZipCode = "zipCode", + /// Country = "country", + /// BuildingId = "buildingId", + /// TenantId = "tenantId", + /// }, + /// } + /// ) + /// ), + /// NullableArray = new List<string>() { "nullableArray", "nullableArray" }, + /// OptionalArray = new List<string>() { "optionalArray", "optionalArray" }, + /// OptionalNullableArray = new List<string>() + /// { + /// "optionalNullableArray", + /// "optionalNullableArray", + /// }, + /// NullableListOfNullables = new List<string?>() + /// { + /// "nullableListOfNullables", + /// "nullableListOfNullables", + /// }, + /// NullableMapOfNullables = new Dictionary<string, Address?>() + /// { + /// { + /// "nullableMapOfNullables", + /// new Address + /// { + /// Street = "street", + /// City = "city", + /// State = "state", + /// ZipCode = "zipCode", + /// Country = "country", + /// BuildingId = "buildingId", + /// TenantId = "tenantId", + /// } + /// }, + /// }, + /// NullableListOfUnions = new List<NotificationMethod>() + /// { + /// new NotificationMethod( + /// new NotificationMethod.Email( + /// new EmailNotification + /// { + /// EmailAddress = "emailAddress", + /// Subject = "subject", + /// HtmlContent = "htmlContent", + /// } + /// ) + /// ), + /// new NotificationMethod( + /// new NotificationMethod.Email( + /// new EmailNotification + /// { + /// EmailAddress = "emailAddress", + /// Subject = "subject", + /// HtmlContent = "htmlContent", + /// } + /// ) + /// ), + /// }, + /// OptionalMapOfEnums = new Dictionary<string, UserRole>() + /// { + /// { "optionalMapOfEnums", UserRole.Admin }, + /// }, + /// } + /// ); + /// + public async Task CreateComplexProfileAsync( + ComplexProfile request, + RequestOptions? options = null, + CancellationToken cancellationToken = default + ) + { + var response = await _client + .SendRequestAsync( + new JsonRequest + { + BaseUrl = _client.Options.BaseUrl, + Method = HttpMethod.Post, + Path = "/api/profiles/complex", + Body = request, + Options = options, + }, + cancellationToken + ) + .ConfigureAwait(false); + if (response.StatusCode is >= 200 and < 400) + { + var responseBody = await response.Raw.Content.ReadAsStringAsync(); + try + { + return JsonUtils.Deserialize(responseBody)!; + } + catch (JsonException e) + { + throw new SeedNullableOptionalException("Failed to deserialize response", e); + } + } + + { + var responseBody = await response.Raw.Content.ReadAsStringAsync(); + throw new SeedNullableOptionalApiException( + $"Error with status code {response.StatusCode}", + response.StatusCode, + responseBody + ); + } + } + + /// + /// Get a complex profile by ID + /// + /// + /// await client.NullableOptional.GetComplexProfileAsync("profileId"); + /// + public async Task GetComplexProfileAsync( + string profileId, + RequestOptions? options = null, + CancellationToken cancellationToken = default + ) + { + var response = await _client + .SendRequestAsync( + new JsonRequest + { + BaseUrl = _client.Options.BaseUrl, + Method = HttpMethod.Get, + Path = string.Format( + "/api/profiles/complex/{0}", + ValueConvert.ToPathParameterString(profileId) + ), + Options = options, + }, + cancellationToken + ) + .ConfigureAwait(false); + if (response.StatusCode is >= 200 and < 400) + { + var responseBody = await response.Raw.Content.ReadAsStringAsync(); + try + { + return JsonUtils.Deserialize(responseBody)!; + } + catch (JsonException e) + { + throw new SeedNullableOptionalException("Failed to deserialize response", e); + } + } + + { + var responseBody = await response.Raw.Content.ReadAsStringAsync(); + throw new SeedNullableOptionalApiException( + $"Error with status code {response.StatusCode}", + response.StatusCode, + responseBody + ); + } + } + + /// + /// Update complex profile to test nullable field updates + /// + /// + /// await client.NullableOptional.UpdateComplexProfileAsync( + /// "profileId", + /// new UpdateComplexProfileRequest + /// { + /// NullableRole = UserRole.Admin, + /// NullableStatus = UserStatus.Active, + /// NullableNotification = new NotificationMethod( + /// new NotificationMethod.Email( + /// new EmailNotification + /// { + /// EmailAddress = "emailAddress", + /// Subject = "subject", + /// HtmlContent = "htmlContent", + /// } + /// ) + /// ), + /// NullableSearchResult = new SearchResult( + /// new SearchResult.User( + /// new UserResponse + /// { + /// Id = "id", + /// Username = "username", + /// Email = "email", + /// Phone = "phone", + /// CreatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000), + /// UpdatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000), + /// Address = new Address + /// { + /// Street = "street", + /// City = "city", + /// State = "state", + /// ZipCode = "zipCode", + /// Country = "country", + /// BuildingId = "buildingId", + /// TenantId = "tenantId", + /// }, + /// } + /// ) + /// ), + /// NullableArray = new List<string>() { "nullableArray", "nullableArray" }, + /// } + /// ); + /// + public async Task UpdateComplexProfileAsync( + string profileId, + UpdateComplexProfileRequest request, + RequestOptions? options = null, + CancellationToken cancellationToken = default + ) + { + var response = await _client + .SendRequestAsync( + new JsonRequest + { + BaseUrl = _client.Options.BaseUrl, + Method = HttpMethodExtensions.Patch, + Path = string.Format( + "/api/profiles/complex/{0}", + ValueConvert.ToPathParameterString(profileId) + ), + Body = request, + Options = options, + }, + cancellationToken + ) + .ConfigureAwait(false); + if (response.StatusCode is >= 200 and < 400) + { + var responseBody = await response.Raw.Content.ReadAsStringAsync(); + try + { + return JsonUtils.Deserialize(responseBody)!; + } + catch (JsonException e) + { + throw new SeedNullableOptionalException("Failed to deserialize response", e); + } + } + + { + var responseBody = await response.Raw.Content.ReadAsStringAsync(); + throw new SeedNullableOptionalApiException( + $"Error with status code {response.StatusCode}", + response.StatusCode, + responseBody + ); + } + } + + /// + /// Test endpoint for validating null deserialization + /// + /// + /// await client.NullableOptional.TestDeserializationAsync( + /// new DeserializationTestRequest + /// { + /// RequiredString = "requiredString", + /// NullableString = "nullableString", + /// OptionalString = "optionalString", + /// OptionalNullableString = "optionalNullableString", + /// NullableEnum = UserRole.Admin, + /// OptionalEnum = UserStatus.Active, + /// NullableUnion = new NotificationMethod( + /// new NotificationMethod.Email( + /// new EmailNotification + /// { + /// EmailAddress = "emailAddress", + /// Subject = "subject", + /// HtmlContent = "htmlContent", + /// } + /// ) + /// ), + /// OptionalUnion = new SearchResult( + /// new SearchResult.User( + /// new UserResponse + /// { + /// Id = "id", + /// Username = "username", + /// Email = "email", + /// Phone = "phone", + /// CreatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000), + /// UpdatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000), + /// Address = new Address + /// { + /// Street = "street", + /// City = "city", + /// State = "state", + /// ZipCode = "zipCode", + /// Country = "country", + /// BuildingId = "buildingId", + /// TenantId = "tenantId", + /// }, + /// } + /// ) + /// ), + /// NullableList = new List<string>() { "nullableList", "nullableList" }, + /// NullableMap = new Dictionary<string, int>() { { "nullableMap", 1 } }, + /// NullableObject = new Address + /// { + /// Street = "street", + /// City = "city", + /// State = "state", + /// ZipCode = "zipCode", + /// Country = "country", + /// BuildingId = "buildingId", + /// TenantId = "tenantId", + /// }, + /// OptionalObject = new Organization + /// { + /// Id = "id", + /// Name = "name", + /// Domain = "domain", + /// EmployeeCount = 1, + /// }, + /// } + /// ); + /// + public async Task TestDeserializationAsync( + DeserializationTestRequest request, + RequestOptions? options = null, + CancellationToken cancellationToken = default + ) + { + var response = await _client + .SendRequestAsync( + new JsonRequest + { + BaseUrl = _client.Options.BaseUrl, + Method = HttpMethod.Post, + Path = "/api/test/deserialization", + Body = request, + Options = options, + }, + cancellationToken + ) + .ConfigureAwait(false); + if (response.StatusCode is >= 200 and < 400) + { + var responseBody = await response.Raw.Content.ReadAsStringAsync(); + try + { + return JsonUtils.Deserialize(responseBody)!; + } + catch (JsonException e) + { + throw new SeedNullableOptionalException("Failed to deserialize response", e); + } + } + + { + var responseBody = await response.Raw.Content.ReadAsStringAsync(); + throw new SeedNullableOptionalApiException( + $"Error with status code {response.StatusCode}", + response.StatusCode, + responseBody + ); + } + } + + /// + /// Filter users by role with nullable enum + /// + /// + /// await client.NullableOptional.FilterByRoleAsync( + /// new FilterByRoleRequest + /// { + /// Role = UserRole.Admin, + /// Status = UserStatus.Active, + /// SecondaryRole = UserRole.Admin, + /// } + /// ); + /// + public async Task> FilterByRoleAsync( + FilterByRoleRequest request, + RequestOptions? options = null, + CancellationToken cancellationToken = default + ) + { + var _query = new Dictionary(); + _query["role"] = request.Role.Value.ToString(); + if (request.Status != null) + { + _query["status"] = request.Status.Stringify(); + } + if (request.SecondaryRole.IsDefined) + { + _query["secondaryRole"] = request.SecondaryRole.Value.ToString(); + } + var response = await _client + .SendRequestAsync( + new JsonRequest + { + BaseUrl = _client.Options.BaseUrl, + Method = HttpMethod.Get, + Path = "/api/users/filter", + Query = _query, + Options = options, + }, + cancellationToken + ) + .ConfigureAwait(false); + if (response.StatusCode is >= 200 and < 400) + { + var responseBody = await response.Raw.Content.ReadAsStringAsync(); + try + { + return JsonUtils.Deserialize>(responseBody)!; + } + catch (JsonException e) + { + throw new SeedNullableOptionalException("Failed to deserialize response", e); + } + } + + { + var responseBody = await response.Raw.Content.ReadAsStringAsync(); + throw new SeedNullableOptionalApiException( + $"Error with status code {response.StatusCode}", + response.StatusCode, + responseBody + ); + } + } + + /// + /// Get notification settings which may be null + /// + /// + /// await client.NullableOptional.GetNotificationSettingsAsync("userId"); + /// + public async Task GetNotificationSettingsAsync( + string userId, + RequestOptions? options = null, + CancellationToken cancellationToken = default + ) + { + var response = await _client + .SendRequestAsync( + new JsonRequest + { + BaseUrl = _client.Options.BaseUrl, + Method = HttpMethod.Get, + Path = string.Format( + "/api/users/{0}/notifications", + ValueConvert.ToPathParameterString(userId) + ), + Options = options, + }, + cancellationToken + ) + .ConfigureAwait(false); + if (response.StatusCode is >= 200 and < 400) + { + var responseBody = await response.Raw.Content.ReadAsStringAsync(); + try + { + return JsonUtils.Deserialize(responseBody)!; + } + catch (JsonException e) + { + throw new SeedNullableOptionalException("Failed to deserialize response", e); + } + } + + { + var responseBody = await response.Raw.Content.ReadAsStringAsync(); + throw new SeedNullableOptionalApiException( + $"Error with status code {response.StatusCode}", + response.StatusCode, + responseBody + ); + } + } + + /// + /// Update tags to test array handling + /// + /// + /// await client.NullableOptional.UpdateTagsAsync( + /// "userId", + /// new UpdateTagsRequest + /// { + /// Tags = new List<string>() { "tags", "tags" }, + /// Categories = new List<string>() { "categories", "categories" }, + /// Labels = new List<string>() { "labels", "labels" }, + /// } + /// ); + /// + public async Task> UpdateTagsAsync( + string userId, + UpdateTagsRequest request, + RequestOptions? options = null, + CancellationToken cancellationToken = default + ) + { + var response = await _client + .SendRequestAsync( + new JsonRequest + { + BaseUrl = _client.Options.BaseUrl, + Method = HttpMethod.Put, + Path = string.Format( + "/api/users/{0}/tags", + ValueConvert.ToPathParameterString(userId) + ), + Body = request, + Options = options, + }, + cancellationToken + ) + .ConfigureAwait(false); + if (response.StatusCode is >= 200 and < 400) + { + var responseBody = await response.Raw.Content.ReadAsStringAsync(); + try + { + return JsonUtils.Deserialize>(responseBody)!; + } + catch (JsonException e) + { + throw new SeedNullableOptionalException("Failed to deserialize response", e); + } + } + + { + var responseBody = await response.Raw.Content.ReadAsStringAsync(); + throw new SeedNullableOptionalApiException( + $"Error with status code {response.StatusCode}", + response.StatusCode, + responseBody + ); + } + } + + /// + /// Get search results with nullable unions + /// + /// + /// await client.NullableOptional.GetSearchResultsAsync( + /// new SearchRequest + /// { + /// Query = "query", + /// Filters = new Dictionary<string, string?>() { { "filters", "filters" } }, + /// IncludeTypes = new List<string>() { "includeTypes", "includeTypes" }, + /// } + /// ); + /// + public async Task?> GetSearchResultsAsync( + SearchRequest request, + RequestOptions? options = null, + CancellationToken cancellationToken = default + ) + { + var response = await _client + .SendRequestAsync( + new JsonRequest + { + BaseUrl = _client.Options.BaseUrl, + Method = HttpMethod.Post, + Path = "/api/search", + Body = request, + Options = options, + }, + cancellationToken + ) + .ConfigureAwait(false); + if (response.StatusCode is >= 200 and < 400) + { + var responseBody = await response.Raw.Content.ReadAsStringAsync(); + try + { + return JsonUtils.Deserialize?>(responseBody)!; + } + catch (JsonException e) + { + throw new SeedNullableOptionalException("Failed to deserialize response", e); + } + } + + { + var responseBody = await response.Raw.Content.ReadAsStringAsync(); + throw new SeedNullableOptionalApiException( + $"Error with status code {response.StatusCode}", + response.StatusCode, + responseBody + ); + } + } +} diff --git a/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Requests/FilterByRoleRequest.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Requests/FilterByRoleRequest.cs new file mode 100644 index 000000000000..c986fc70f4e6 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Requests/FilterByRoleRequest.cs @@ -0,0 +1,23 @@ +using System.Text.Json.Serialization; +using SeedNullableOptional.Core; + +namespace SeedNullableOptional; + +[Serializable] +public record FilterByRoleRequest +{ + [JsonIgnore] + public UserRole? Role { get; set; } + + [JsonIgnore] + public UserStatus? Status { get; set; } + + [JsonIgnore] + public Optional SecondaryRole { get; set; } + + /// + public override string ToString() + { + return JsonUtils.Serialize(this); + } +} diff --git a/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Requests/ListUsersRequest.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Requests/ListUsersRequest.cs new file mode 100644 index 000000000000..304a8c1530ae --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Requests/ListUsersRequest.cs @@ -0,0 +1,26 @@ +using System.Text.Json.Serialization; +using SeedNullableOptional.Core; + +namespace SeedNullableOptional; + +[Serializable] +public record ListUsersRequest +{ + [JsonIgnore] + public int? Limit { get; set; } + + [JsonIgnore] + public int? Offset { get; set; } + + [JsonIgnore] + public bool? IncludeDeleted { get; set; } + + [JsonIgnore] + public Optional SortBy { get; set; } + + /// + public override string ToString() + { + return JsonUtils.Serialize(this); + } +} diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/NullableOptional/Requests/SearchRequest.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Requests/SearchRequest.cs similarity index 94% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/NullableOptional/Requests/SearchRequest.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Requests/SearchRequest.cs index 80f3ad4d9a2f..c8577a6059e5 100644 --- a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/NullableOptional/Requests/SearchRequest.cs +++ b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Requests/SearchRequest.cs @@ -9,9 +9,11 @@ public record SearchRequest [JsonPropertyName("query")] public required string Query { get; set; } + [Optional] [JsonPropertyName("filters")] public Dictionary? Filters { get; set; } + [Nullable] [JsonPropertyName("includeTypes")] public IEnumerable? IncludeTypes { get; set; } diff --git a/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Requests/SearchUsersRequest.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Requests/SearchUsersRequest.cs new file mode 100644 index 000000000000..cef3d87bfbcf --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Requests/SearchUsersRequest.cs @@ -0,0 +1,26 @@ +using System.Text.Json.Serialization; +using SeedNullableOptional.Core; + +namespace SeedNullableOptional; + +[Serializable] +public record SearchUsersRequest +{ + [JsonIgnore] + public required string Query { get; set; } + + [JsonIgnore] + public string? Department { get; set; } + + [JsonIgnore] + public string? Role { get; set; } + + [JsonIgnore] + public Optional IsActive { get; set; } + + /// + public override string ToString() + { + return JsonUtils.Serialize(this); + } +} diff --git a/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Requests/UpdateComplexProfileRequest.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Requests/UpdateComplexProfileRequest.cs new file mode 100644 index 000000000000..0885901f0688 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Requests/UpdateComplexProfileRequest.cs @@ -0,0 +1,34 @@ +using System.Text.Json.Serialization; +using SeedNullableOptional.Core; + +namespace SeedNullableOptional; + +[Serializable] +public record UpdateComplexProfileRequest +{ + [Nullable, Optional] + [JsonPropertyName("nullableRole")] + public Optional NullableRole { get; set; } + + [Nullable, Optional] + [JsonPropertyName("nullableStatus")] + public Optional NullableStatus { get; set; } + + [Nullable, Optional] + [JsonPropertyName("nullableNotification")] + public Optional NullableNotification { get; set; } + + [Nullable, Optional] + [JsonPropertyName("nullableSearchResult")] + public Optional NullableSearchResult { get; set; } + + [Nullable, Optional] + [JsonPropertyName("nullableArray")] + public Optional?> NullableArray { get; set; } + + /// + public override string ToString() + { + return JsonUtils.Serialize(this); + } +} diff --git a/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Requests/UpdateTagsRequest.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Requests/UpdateTagsRequest.cs new file mode 100644 index 000000000000..e86bb74828ba --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Requests/UpdateTagsRequest.cs @@ -0,0 +1,26 @@ +using System.Text.Json.Serialization; +using SeedNullableOptional.Core; + +namespace SeedNullableOptional; + +[Serializable] +public record UpdateTagsRequest +{ + [Nullable] + [JsonPropertyName("tags")] + public IEnumerable? Tags { get; set; } + + [Optional] + [JsonPropertyName("categories")] + public IEnumerable? Categories { get; set; } + + [Nullable, Optional] + [JsonPropertyName("labels")] + public Optional?> Labels { get; set; } + + /// + public override string ToString() + { + return JsonUtils.Serialize(this); + } +} diff --git a/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Types/Address.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Types/Address.cs new file mode 100644 index 000000000000..68df7f599545 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Types/Address.cs @@ -0,0 +1,54 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using SeedNullableOptional.Core; + +namespace SeedNullableOptional; + +/// +/// Nested object for testing +/// +[Serializable] +public record Address : IJsonOnDeserialized +{ + [JsonExtensionData] + private readonly IDictionary _extensionData = + new Dictionary(); + + [JsonPropertyName("street")] + public required string Street { get; set; } + + [Nullable] + [JsonPropertyName("city")] + public string? City { get; set; } + + [Optional] + [JsonPropertyName("state")] + public string? State { get; set; } + + [JsonPropertyName("zipCode")] + public required string ZipCode { get; set; } + + [Nullable, Optional] + [JsonPropertyName("country")] + public Optional Country { get; set; } + + [Nullable] + [JsonPropertyName("buildingId")] + public string? BuildingId { get; set; } + + [Optional] + [JsonPropertyName("tenantId")] + public string? TenantId { get; set; } + + [JsonIgnore] + public ReadOnlyAdditionalProperties AdditionalProperties { get; private set; } = new(); + + void IJsonOnDeserialized.OnDeserialized() => + AdditionalProperties.CopyFromExtensionData(_extensionData); + + /// + public override string ToString() + { + return JsonUtils.Serialize(this); + } +} diff --git a/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Types/ComplexProfile.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Types/ComplexProfile.cs new file mode 100644 index 000000000000..321b49ca63f4 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Types/ComplexProfile.cs @@ -0,0 +1,103 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using SeedNullableOptional.Core; + +namespace SeedNullableOptional; + +/// +/// Test object with nullable enums, unions, and arrays +/// +[Serializable] +public record ComplexProfile : IJsonOnDeserialized +{ + [JsonExtensionData] + private readonly IDictionary _extensionData = + new Dictionary(); + + [JsonPropertyName("id")] + public required string Id { get; set; } + + [Nullable] + [JsonPropertyName("nullableRole")] + public UserRole? NullableRole { get; set; } + + [Optional] + [JsonPropertyName("optionalRole")] + public UserRole? OptionalRole { get; set; } + + [Nullable, Optional] + [JsonPropertyName("optionalNullableRole")] + public Optional OptionalNullableRole { get; set; } + + [Nullable] + [JsonPropertyName("nullableStatus")] + public UserStatus? NullableStatus { get; set; } + + [Optional] + [JsonPropertyName("optionalStatus")] + public UserStatus? OptionalStatus { get; set; } + + [Nullable, Optional] + [JsonPropertyName("optionalNullableStatus")] + public Optional OptionalNullableStatus { get; set; } + + [Nullable] + [JsonPropertyName("nullableNotification")] + public NotificationMethod? NullableNotification { get; set; } + + [Optional] + [JsonPropertyName("optionalNotification")] + public NotificationMethod? OptionalNotification { get; set; } + + [Nullable, Optional] + [JsonPropertyName("optionalNullableNotification")] + public Optional OptionalNullableNotification { get; set; } + + [Nullable] + [JsonPropertyName("nullableSearchResult")] + public SearchResult? NullableSearchResult { get; set; } + + [Optional] + [JsonPropertyName("optionalSearchResult")] + public SearchResult? OptionalSearchResult { get; set; } + + [Nullable] + [JsonPropertyName("nullableArray")] + public IEnumerable? NullableArray { get; set; } + + [Optional] + [JsonPropertyName("optionalArray")] + public IEnumerable? OptionalArray { get; set; } + + [Nullable, Optional] + [JsonPropertyName("optionalNullableArray")] + public Optional?> OptionalNullableArray { get; set; } + + [Nullable] + [JsonPropertyName("nullableListOfNullables")] + public IEnumerable? NullableListOfNullables { get; set; } + + [Nullable] + [JsonPropertyName("nullableMapOfNullables")] + public Dictionary? NullableMapOfNullables { get; set; } + + [Nullable] + [JsonPropertyName("nullableListOfUnions")] + public IEnumerable? NullableListOfUnions { get; set; } + + [Optional] + [JsonPropertyName("optionalMapOfEnums")] + public Dictionary? OptionalMapOfEnums { get; set; } + + [JsonIgnore] + public ReadOnlyAdditionalProperties AdditionalProperties { get; private set; } = new(); + + void IJsonOnDeserialized.OnDeserialized() => + AdditionalProperties.CopyFromExtensionData(_extensionData); + + /// + public override string ToString() + { + return JsonUtils.Serialize(this); + } +} diff --git a/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Types/CreateUserRequest.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Types/CreateUserRequest.cs new file mode 100644 index 000000000000..43e293125e00 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Types/CreateUserRequest.cs @@ -0,0 +1,40 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using SeedNullableOptional.Core; + +namespace SeedNullableOptional; + +[Serializable] +public record CreateUserRequest : IJsonOnDeserialized +{ + [JsonExtensionData] + private readonly IDictionary _extensionData = + new Dictionary(); + + [JsonPropertyName("username")] + public required string Username { get; set; } + + [Nullable] + [JsonPropertyName("email")] + public string? Email { get; set; } + + [Optional] + [JsonPropertyName("phone")] + public string? Phone { get; set; } + + [Nullable, Optional] + [JsonPropertyName("address")] + public Optional Address { get; set; } + + [JsonIgnore] + public ReadOnlyAdditionalProperties AdditionalProperties { get; private set; } = new(); + + void IJsonOnDeserialized.OnDeserialized() => + AdditionalProperties.CopyFromExtensionData(_extensionData); + + /// + public override string ToString() + { + return JsonUtils.Serialize(this); + } +} diff --git a/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Types/DeserializationTestRequest.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Types/DeserializationTestRequest.cs new file mode 100644 index 000000000000..7f5f7fb3fcce --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Types/DeserializationTestRequest.cs @@ -0,0 +1,75 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using SeedNullableOptional.Core; + +namespace SeedNullableOptional; + +/// +/// Request body for testing deserialization of null values +/// +[Serializable] +public record DeserializationTestRequest : IJsonOnDeserialized +{ + [JsonExtensionData] + private readonly IDictionary _extensionData = + new Dictionary(); + + [JsonPropertyName("requiredString")] + public required string RequiredString { get; set; } + + [Nullable] + [JsonPropertyName("nullableString")] + public string? NullableString { get; set; } + + [Optional] + [JsonPropertyName("optionalString")] + public string? OptionalString { get; set; } + + [Nullable, Optional] + [JsonPropertyName("optionalNullableString")] + public Optional OptionalNullableString { get; set; } + + [Nullable] + [JsonPropertyName("nullableEnum")] + public UserRole? NullableEnum { get; set; } + + [Optional] + [JsonPropertyName("optionalEnum")] + public UserStatus? OptionalEnum { get; set; } + + [Nullable] + [JsonPropertyName("nullableUnion")] + public NotificationMethod? NullableUnion { get; set; } + + [Optional] + [JsonPropertyName("optionalUnion")] + public SearchResult? OptionalUnion { get; set; } + + [Nullable] + [JsonPropertyName("nullableList")] + public IEnumerable? NullableList { get; set; } + + [Nullable] + [JsonPropertyName("nullableMap")] + public Dictionary? NullableMap { get; set; } + + [Nullable] + [JsonPropertyName("nullableObject")] + public Address? NullableObject { get; set; } + + [Optional] + [JsonPropertyName("optionalObject")] + public Organization? OptionalObject { get; set; } + + [JsonIgnore] + public ReadOnlyAdditionalProperties AdditionalProperties { get; private set; } = new(); + + void IJsonOnDeserialized.OnDeserialized() => + AdditionalProperties.CopyFromExtensionData(_extensionData); + + /// + public override string ToString() + { + return JsonUtils.Serialize(this); + } +} diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/NullableOptional/Types/DeserializationTestResponse.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Types/DeserializationTestResponse.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/NullableOptional/Types/DeserializationTestResponse.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Types/DeserializationTestResponse.cs diff --git a/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Types/Document.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Types/Document.cs new file mode 100644 index 000000000000..7d31dbc72b9b --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Types/Document.cs @@ -0,0 +1,42 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using SeedNullableOptional.Core; + +namespace SeedNullableOptional; + +[Serializable] +public record Document : IJsonOnDeserialized +{ + [JsonExtensionData] + private readonly IDictionary _extensionData = + new Dictionary(); + + [JsonPropertyName("id")] + public required string Id { get; set; } + + [JsonPropertyName("title")] + public required string Title { get; set; } + + [JsonPropertyName("content")] + public required string Content { get; set; } + + [Nullable] + [JsonPropertyName("author")] + public string? Author { get; set; } + + [Optional] + [JsonPropertyName("tags")] + public IEnumerable? Tags { get; set; } + + [JsonIgnore] + public ReadOnlyAdditionalProperties AdditionalProperties { get; private set; } = new(); + + void IJsonOnDeserialized.OnDeserialized() => + AdditionalProperties.CopyFromExtensionData(_extensionData); + + /// + public override string ToString() + { + return JsonUtils.Serialize(this); + } +} diff --git a/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Types/EmailNotification.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Types/EmailNotification.cs new file mode 100644 index 000000000000..b5cfd6f14a9a --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Types/EmailNotification.cs @@ -0,0 +1,35 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using SeedNullableOptional.Core; + +namespace SeedNullableOptional; + +[Serializable] +public record EmailNotification : IJsonOnDeserialized +{ + [JsonExtensionData] + private readonly IDictionary _extensionData = + new Dictionary(); + + [JsonPropertyName("emailAddress")] + public required string EmailAddress { get; set; } + + [JsonPropertyName("subject")] + public required string Subject { get; set; } + + [Optional] + [JsonPropertyName("htmlContent")] + public string? HtmlContent { get; set; } + + [JsonIgnore] + public ReadOnlyAdditionalProperties AdditionalProperties { get; private set; } = new(); + + void IJsonOnDeserialized.OnDeserialized() => + AdditionalProperties.CopyFromExtensionData(_extensionData); + + /// + public override string ToString() + { + return JsonUtils.Serialize(this); + } +} diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/NullableOptional/Types/NotificationMethod.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Types/NotificationMethod.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/NullableOptional/Types/NotificationMethod.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Types/NotificationMethod.cs diff --git a/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Types/Organization.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Types/Organization.cs new file mode 100644 index 000000000000..65afaaa09351 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Types/Organization.cs @@ -0,0 +1,39 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using SeedNullableOptional.Core; + +namespace SeedNullableOptional; + +[Serializable] +public record Organization : IJsonOnDeserialized +{ + [JsonExtensionData] + private readonly IDictionary _extensionData = + new Dictionary(); + + [JsonPropertyName("id")] + public required string Id { get; set; } + + [JsonPropertyName("name")] + public required string Name { get; set; } + + [Nullable] + [JsonPropertyName("domain")] + public string? Domain { get; set; } + + [Optional] + [JsonPropertyName("employeeCount")] + public int? EmployeeCount { get; set; } + + [JsonIgnore] + public ReadOnlyAdditionalProperties AdditionalProperties { get; private set; } = new(); + + void IJsonOnDeserialized.OnDeserialized() => + AdditionalProperties.CopyFromExtensionData(_extensionData); + + /// + public override string ToString() + { + return JsonUtils.Serialize(this); + } +} diff --git a/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Types/PushNotification.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Types/PushNotification.cs new file mode 100644 index 000000000000..781f3ac6298a --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Types/PushNotification.cs @@ -0,0 +1,38 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using SeedNullableOptional.Core; + +namespace SeedNullableOptional; + +[Serializable] +public record PushNotification : IJsonOnDeserialized +{ + [JsonExtensionData] + private readonly IDictionary _extensionData = + new Dictionary(); + + [JsonPropertyName("deviceToken")] + public required string DeviceToken { get; set; } + + [JsonPropertyName("title")] + public required string Title { get; set; } + + [JsonPropertyName("body")] + public required string Body { get; set; } + + [Optional] + [JsonPropertyName("badge")] + public int? Badge { get; set; } + + [JsonIgnore] + public ReadOnlyAdditionalProperties AdditionalProperties { get; private set; } = new(); + + void IJsonOnDeserialized.OnDeserialized() => + AdditionalProperties.CopyFromExtensionData(_extensionData); + + /// + public override string ToString() + { + return JsonUtils.Serialize(this); + } +} diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/NullableOptional/Types/SearchResult.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Types/SearchResult.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/NullableOptional/Types/SearchResult.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Types/SearchResult.cs diff --git a/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Types/SmsNotification.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Types/SmsNotification.cs new file mode 100644 index 000000000000..fcd77f6f1652 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Types/SmsNotification.cs @@ -0,0 +1,35 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using SeedNullableOptional.Core; + +namespace SeedNullableOptional; + +[Serializable] +public record SmsNotification : IJsonOnDeserialized +{ + [JsonExtensionData] + private readonly IDictionary _extensionData = + new Dictionary(); + + [JsonPropertyName("phoneNumber")] + public required string PhoneNumber { get; set; } + + [JsonPropertyName("message")] + public required string Message { get; set; } + + [Optional] + [JsonPropertyName("shortCode")] + public string? ShortCode { get; set; } + + [JsonIgnore] + public ReadOnlyAdditionalProperties AdditionalProperties { get; private set; } = new(); + + void IJsonOnDeserialized.OnDeserialized() => + AdditionalProperties.CopyFromExtensionData(_extensionData); + + /// + public override string ToString() + { + return JsonUtils.Serialize(this); + } +} diff --git a/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Types/UpdateUserRequest.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Types/UpdateUserRequest.cs new file mode 100644 index 000000000000..a98846d7ae78 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Types/UpdateUserRequest.cs @@ -0,0 +1,44 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using SeedNullableOptional.Core; + +namespace SeedNullableOptional; + +/// +/// For testing PATCH operations +/// +[Serializable] +public record UpdateUserRequest : IJsonOnDeserialized +{ + [JsonExtensionData] + private readonly IDictionary _extensionData = + new Dictionary(); + + [Optional] + [JsonPropertyName("username")] + public string? Username { get; set; } + + [Nullable, Optional] + [JsonPropertyName("email")] + public Optional Email { get; set; } + + [Optional] + [JsonPropertyName("phone")] + public string? Phone { get; set; } + + [Nullable, Optional] + [JsonPropertyName("address")] + public Optional Address { get; set; } + + [JsonIgnore] + public ReadOnlyAdditionalProperties AdditionalProperties { get; private set; } = new(); + + void IJsonOnDeserialized.OnDeserialized() => + AdditionalProperties.CopyFromExtensionData(_extensionData); + + /// + public override string ToString() + { + return JsonUtils.Serialize(this); + } +} diff --git a/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Types/UserProfile.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Types/UserProfile.cs new file mode 100644 index 000000000000..b88965bc5c3b --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Types/UserProfile.cs @@ -0,0 +1,98 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using SeedNullableOptional.Core; + +namespace SeedNullableOptional; + +/// +/// Test object with nullable and optional fields +/// +[Serializable] +public record UserProfile : IJsonOnDeserialized +{ + [JsonExtensionData] + private readonly IDictionary _extensionData = + new Dictionary(); + + [JsonPropertyName("id")] + public required string Id { get; set; } + + [JsonPropertyName("username")] + public required string Username { get; set; } + + [Nullable] + [JsonPropertyName("nullableString")] + public string? NullableString { get; set; } + + [Nullable] + [JsonPropertyName("nullableInteger")] + public int? NullableInteger { get; set; } + + [Nullable] + [JsonPropertyName("nullableBoolean")] + public bool? NullableBoolean { get; set; } + + [Nullable] + [JsonPropertyName("nullableDate")] + public DateTime? NullableDate { get; set; } + + [Nullable] + [JsonPropertyName("nullableObject")] + public Address? NullableObject { get; set; } + + [Nullable] + [JsonPropertyName("nullableList")] + public IEnumerable? NullableList { get; set; } + + [Nullable] + [JsonPropertyName("nullableMap")] + public Dictionary? NullableMap { get; set; } + + [Optional] + [JsonPropertyName("optionalString")] + public string? OptionalString { get; set; } + + [Optional] + [JsonPropertyName("optionalInteger")] + public int? OptionalInteger { get; set; } + + [Optional] + [JsonPropertyName("optionalBoolean")] + public bool? OptionalBoolean { get; set; } + + [Optional] + [JsonPropertyName("optionalDate")] + public DateTime? OptionalDate { get; set; } + + [Optional] + [JsonPropertyName("optionalObject")] + public Address? OptionalObject { get; set; } + + [Optional] + [JsonPropertyName("optionalList")] + public IEnumerable? OptionalList { get; set; } + + [Optional] + [JsonPropertyName("optionalMap")] + public Dictionary? OptionalMap { get; set; } + + [Nullable, Optional] + [JsonPropertyName("optionalNullableString")] + public Optional OptionalNullableString { get; set; } + + [Nullable, Optional] + [JsonPropertyName("optionalNullableObject")] + public Optional OptionalNullableObject { get; set; } + + [JsonIgnore] + public ReadOnlyAdditionalProperties AdditionalProperties { get; private set; } = new(); + + void IJsonOnDeserialized.OnDeserialized() => + AdditionalProperties.CopyFromExtensionData(_extensionData); + + /// + public override string ToString() + { + return JsonUtils.Serialize(this); + } +} diff --git a/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Types/UserResponse.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Types/UserResponse.cs new file mode 100644 index 000000000000..ec04eb4fc439 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Types/UserResponse.cs @@ -0,0 +1,50 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using SeedNullableOptional.Core; + +namespace SeedNullableOptional; + +[Serializable] +public record UserResponse : IJsonOnDeserialized +{ + [JsonExtensionData] + private readonly IDictionary _extensionData = + new Dictionary(); + + [JsonPropertyName("id")] + public required string Id { get; set; } + + [JsonPropertyName("username")] + public required string Username { get; set; } + + [Nullable] + [JsonPropertyName("email")] + public string? Email { get; set; } + + [Optional] + [JsonPropertyName("phone")] + public string? Phone { get; set; } + + [JsonPropertyName("createdAt")] + public required DateTime CreatedAt { get; set; } + + [Nullable] + [JsonPropertyName("updatedAt")] + public DateTime? UpdatedAt { get; set; } + + [Optional] + [JsonPropertyName("address")] + public Address? Address { get; set; } + + [JsonIgnore] + public ReadOnlyAdditionalProperties AdditionalProperties { get; private set; } = new(); + + void IJsonOnDeserialized.OnDeserialized() => + AdditionalProperties.CopyFromExtensionData(_extensionData); + + /// + public override string ToString() + { + return JsonUtils.Serialize(this); + } +} diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/NullableOptional/Types/UserRole.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Types/UserRole.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/NullableOptional/Types/UserRole.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Types/UserRole.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/NullableOptional/Types/UserStatus.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Types/UserStatus.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/NullableOptional/Types/UserStatus.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/NullableOptional/Types/UserStatus.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/SeedNullableOptional.Custom.props b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/SeedNullableOptional.Custom.props similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/SeedNullableOptional.Custom.props rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/SeedNullableOptional.Custom.props diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/SeedNullableOptional.csproj b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/SeedNullableOptional.csproj similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/SeedNullableOptional.csproj rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/SeedNullableOptional.csproj diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/SeedNullableOptionalClient.cs b/seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/SeedNullableOptionalClient.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/SeedNullableOptionalClient.cs rename to seed/csharp-sdk/nullable-optional/explicit-nullable-optional/src/SeedNullableOptional/SeedNullableOptionalClient.cs diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/.editorconfig b/seed/csharp-sdk/nullable-optional/no-custom-config/.editorconfig new file mode 100644 index 000000000000..1e7a0adbac80 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/.editorconfig @@ -0,0 +1,35 @@ +root = true + +[*.cs] +resharper_arrange_object_creation_when_type_evident_highlighting = hint +resharper_auto_property_can_be_made_get_only_global_highlighting = hint +resharper_check_namespace_highlighting = hint +resharper_class_never_instantiated_global_highlighting = hint +resharper_class_never_instantiated_local_highlighting = hint +resharper_collection_never_updated_global_highlighting = hint +resharper_convert_type_check_pattern_to_null_check_highlighting = hint +resharper_inconsistent_naming_highlighting = hint +resharper_member_can_be_private_global_highlighting = hint +resharper_member_hides_static_from_outer_class_highlighting = hint +resharper_not_accessed_field_local_highlighting = hint +resharper_nullable_warning_suppression_is_used_highlighting = suggestion +resharper_partial_type_with_single_part_highlighting = hint +resharper_prefer_concrete_value_over_default_highlighting = none +resharper_private_field_can_be_converted_to_local_variable_highlighting = hint +resharper_property_can_be_made_init_only_global_highlighting = hint +resharper_property_can_be_made_init_only_local_highlighting = hint +resharper_redundant_name_qualifier_highlighting = none +resharper_redundant_using_directive_highlighting = hint +resharper_replace_slice_with_range_indexer_highlighting = none +resharper_unused_auto_property_accessor_global_highlighting = hint +resharper_unused_auto_property_accessor_local_highlighting = hint +resharper_unused_member_global_highlighting = hint +resharper_unused_type_global_highlighting = hint +resharper_use_string_interpolation_highlighting = hint +dotnet_diagnostic.CS1591.severity = suggestion + +[src/**/Types/*.cs] +resharper_check_namespace_highlighting = none + +[src/**/Core/Public/*.cs] +resharper_check_namespace_highlighting = none \ No newline at end of file diff --git a/seed/csharp-sdk/nullable-optional/.fern/metadata.json b/seed/csharp-sdk/nullable-optional/no-custom-config/.fern/metadata.json similarity index 100% rename from seed/csharp-sdk/nullable-optional/.fern/metadata.json rename to seed/csharp-sdk/nullable-optional/no-custom-config/.fern/metadata.json diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/.github/workflows/ci.yml b/seed/csharp-sdk/nullable-optional/no-custom-config/.github/workflows/ci.yml new file mode 100644 index 000000000000..9c1473925321 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/.github/workflows/ci.yml @@ -0,0 +1,97 @@ +name: ci + +on: [push] + +env: + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true + DOTNET_NOLOGO: true + +jobs: + compile: + runs-on: ubuntu-latest + + steps: + - name: Checkout repo + uses: actions/checkout@v5 + + - uses: actions/checkout@master + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.x + + - name: Print .NET info + run: dotnet --info + + - name: Install tools + run: dotnet tool restore + + - name: Restore dependencies + run: dotnet restore src/SeedNullableOptional/SeedNullableOptional.csproj + + - name: Build Release + run: dotnet build src/SeedNullableOptional/SeedNullableOptional.csproj -c Release --no-restore + + unit-tests: + runs-on: ubuntu-latest + + steps: + - name: Checkout repo + uses: actions/checkout@v5 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.x + + - name: Print .NET info + run: dotnet --info + + - name: Install tools + run: | + dotnet tool restore + + - name: Restore dependencies + run: dotnet restore src/SeedNullableOptional.Test/SeedNullableOptional.Test.csproj + + - name: Build Release + run: dotnet build src/SeedNullableOptional.Test/SeedNullableOptional.Test.csproj -c Release --no-restore + + - name: Run Tests + run: dotnet test src/SeedNullableOptional.Test/SeedNullableOptional.Test.csproj -c Release --no-build --no-restore + + + publish: + needs: [compile] + if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') + runs-on: ubuntu-latest + + steps: + - name: Checkout repo + uses: actions/checkout@v5 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.x + + - name: Print .NET info + run: dotnet --info + + - name: Install tools + run: dotnet tool restore + + - name: Restore dependencies + run: dotnet restore src/SeedNullableOptional/SeedNullableOptional.csproj + + - name: Build Release + run: dotnet build src/SeedNullableOptional/SeedNullableOptional.csproj -c Release --no-restore + + - name: Publish + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_TOKEN }} + run: | + dotnet pack src/SeedNullableOptional/SeedNullableOptional.csproj -c Release --no-build --no-restore + dotnet nuget push src/SeedNullableOptional/bin/Release/*.nupkg --api-key $NUGET_API_KEY --source "nuget.org" + diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/.gitignore b/seed/csharp-sdk/nullable-optional/no-custom-config/.gitignore new file mode 100644 index 000000000000..11014f2b33d7 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/.gitignore @@ -0,0 +1,484 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +## This is based on `dotnet new gitignore` and customized by Fern + +# dotenv files +.env + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +# [Rr]elease/ (Ignored by Fern) +# [Rr]eleases/ (Ignored by Fern) +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +# [Ll]og/ (Ignored by Fern) +# [Ll]ogs/ (Ignored by Fern) + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET +project.lock.json +project.fragment.lock.json +artifacts/ + +# Tye +.tye/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml +.idea + +## +## Visual studio for Mac +## + + +# globs +Makefile.in +*.userprefs +*.usertasks +config.make +config.status +aclocal.m4 +install-sh +autom4te.cache/ +*.tar.gz +tarballs/ +test-results/ + +# Mac bundle stuff +*.dmg +*.app + +# content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# Vim temporary swap files +*.swp diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/README.md b/seed/csharp-sdk/nullable-optional/no-custom-config/README.md new file mode 100644 index 000000000000..5bdfdd3ecfa4 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/README.md @@ -0,0 +1,154 @@ +# Seed C# Library + +[![fern shield](https://img.shields.io/badge/%F0%9F%8C%BF-Built%20with%20Fern-brightgreen)](https://buildwithfern.com?utm_source=github&utm_medium=github&utm_campaign=readme&utm_source=Seed%2FC%23) +[![nuget shield](https://img.shields.io/nuget/v/SeedNullableOptional)](https://nuget.org/packages/SeedNullableOptional) + +The Seed C# library provides convenient access to the Seed APIs from C#. + +## Table of Contents + +- [Requirements](#requirements) +- [Installation](#installation) +- [Reference](#reference) +- [Usage](#usage) +- [Exception Handling](#exception-handling) +- [Advanced](#advanced) + - [Retries](#retries) + - [Timeouts](#timeouts) + - [Forward Compatible Enums](#forward-compatible-enums) +- [Contributing](#contributing) + +## Requirements + +This SDK requires: + +## Installation + +```sh +dotnet add package SeedNullableOptional +``` + +## Reference + +A full reference for this library is available [here](./reference.md). + +## Usage + +Instantiate and use the client with the following: + +```csharp +using SeedNullableOptional; + +var client = new SeedNullableOptionalClient(); +await client.NullableOptional.CreateUserAsync( + new CreateUserRequest + { + Username = "username", + Email = "email", + Phone = "phone", + Address = new Address + { + Street = "street", + City = "city", + State = "state", + ZipCode = "zipCode", + Country = "country", + BuildingId = "buildingId", + TenantId = "tenantId", + }, + } +); +``` + +## Exception Handling + +When the API returns a non-success status code (4xx or 5xx response), a subclass of the following error +will be thrown. + +```csharp +using SeedNullableOptional; + +try { + var response = await client.NullableOptional.CreateUserAsync(...); +} catch (SeedNullableOptionalApiException e) { + System.Console.WriteLine(e.Body); + System.Console.WriteLine(e.StatusCode); +} +``` + +## Advanced + +### Retries + +The SDK is instrumented with automatic retries with exponential backoff. A request will be retried as long +as the request is deemed retryable and the number of retry attempts has not grown larger than the configured +retry limit (default: 2). + +A request is deemed retryable when any of the following HTTP status codes is returned: + +- [408](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) (Timeout) +- [429](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) (Too Many Requests) +- [5XX](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500) (Internal Server Errors) + +Use the `MaxRetries` request option to configure this behavior. + +```csharp +var response = await client.NullableOptional.CreateUserAsync( + ..., + new RequestOptions { + MaxRetries: 0 // Override MaxRetries at the request level + } +); +``` + +### Timeouts + +The SDK defaults to a 30 second timeout. Use the `Timeout` option to configure this behavior. + +```csharp +var response = await client.NullableOptional.CreateUserAsync( + ..., + new RequestOptions { + Timeout: TimeSpan.FromSeconds(3) // Override timeout to 3s + } +); +``` + +### Forward Compatible Enums + +This SDK uses forward-compatible enums that can handle unknown values gracefully. + +```csharp +using SeedNullableOptional; + +// Using a built-in value +var userRole = UserRole.Admin; + +// Using a custom value +var customUserRole = UserRole.FromCustom("custom-value"); + +// Using in a switch statement +switch (userRole.Value) +{ + case UserRole.Values.Admin: + Console.WriteLine("Admin"); + break; + default: + Console.WriteLine($"Unknown value: {userRole.Value}"); + break; +} + +// Explicit casting +string userRoleString = (string)UserRole.Admin; +UserRole userRoleFromString = (UserRole)"ADMIN"; +``` + +## Contributing + +While we value open-source contributions to this SDK, this library is generated programmatically. +Additions made directly to this library would have to be moved over to our generation code, +otherwise they would be overwritten upon the next generated release. Feel free to open a PR as +a proof of concept, but know that we will not be able to merge it as-is. We suggest opening +an issue first to discuss with us! + +On the other hand, contributions to the README are always very welcome! \ No newline at end of file diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/SeedNullableOptional.slnx b/seed/csharp-sdk/nullable-optional/no-custom-config/SeedNullableOptional.slnx new file mode 100644 index 000000000000..a758ffa5f8ac --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/SeedNullableOptional.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/reference.md b/seed/csharp-sdk/nullable-optional/no-custom-config/reference.md new file mode 100644 index 000000000000..632a4ce39520 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/reference.md @@ -0,0 +1,1045 @@ +# Reference +## NullableOptional +
client.NullableOptional.GetUserAsync(userId) -> UserResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Get a user by ID +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```csharp +await client.NullableOptional.GetUserAsync("userId"); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**userId:** `string` + +
+
+
+
+ + +
+
+
+ +
client.NullableOptional.CreateUserAsync(CreateUserRequest { ... }) -> UserResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Create a new user +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```csharp +await client.NullableOptional.CreateUserAsync( + new CreateUserRequest + { + Username = "username", + Email = "email", + Phone = "phone", + Address = new Address + { + Street = "street", + City = "city", + State = "state", + ZipCode = "zipCode", + Country = "country", + BuildingId = "buildingId", + TenantId = "tenantId", + }, + } +); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request:** `CreateUserRequest` + +
+
+
+
+ + +
+
+
+ +
client.NullableOptional.UpdateUserAsync(userId, UpdateUserRequest { ... }) -> UserResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Update a user (partial update) +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```csharp +await client.NullableOptional.UpdateUserAsync( + "userId", + new UpdateUserRequest + { + Username = "username", + Email = "email", + Phone = "phone", + Address = new Address + { + Street = "street", + City = "city", + State = "state", + ZipCode = "zipCode", + Country = "country", + BuildingId = "buildingId", + TenantId = "tenantId", + }, + } +); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**userId:** `string` + +
+
+ +
+
+ +**request:** `UpdateUserRequest` + +
+
+
+
+ + +
+
+
+ +
client.NullableOptional.ListUsersAsync(ListUsersRequest { ... }) -> IEnumerable<UserResponse> +
+
+ +#### 📝 Description + +
+
+ +
+
+ +List all users +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```csharp +await client.NullableOptional.ListUsersAsync( + new ListUsersRequest + { + Limit = 1, + Offset = 1, + IncludeDeleted = true, + SortBy = "sortBy", + } +); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request:** `ListUsersRequest` + +
+
+
+
+ + +
+
+
+ +
client.NullableOptional.SearchUsersAsync(SearchUsersRequest { ... }) -> IEnumerable<UserResponse> +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Search users +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```csharp +await client.NullableOptional.SearchUsersAsync( + new SearchUsersRequest + { + Query = "query", + Department = "department", + Role = "role", + IsActive = true, + } +); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request:** `SearchUsersRequest` + +
+
+
+
+ + +
+
+
+ +
client.NullableOptional.CreateComplexProfileAsync(ComplexProfile { ... }) -> ComplexProfile +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Create a complex profile to test nullable enums and unions +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```csharp +await client.NullableOptional.CreateComplexProfileAsync( + new ComplexProfile + { + Id = "id", + NullableRole = UserRole.Admin, + OptionalRole = UserRole.Admin, + OptionalNullableRole = UserRole.Admin, + NullableStatus = UserStatus.Active, + OptionalStatus = UserStatus.Active, + OptionalNullableStatus = UserStatus.Active, + NullableNotification = new NotificationMethod( + new NotificationMethod.Email( + new EmailNotification + { + EmailAddress = "emailAddress", + Subject = "subject", + HtmlContent = "htmlContent", + } + ) + ), + OptionalNotification = new NotificationMethod( + new NotificationMethod.Email( + new EmailNotification + { + EmailAddress = "emailAddress", + Subject = "subject", + HtmlContent = "htmlContent", + } + ) + ), + OptionalNullableNotification = new NotificationMethod( + new NotificationMethod.Email( + new EmailNotification + { + EmailAddress = "emailAddress", + Subject = "subject", + HtmlContent = "htmlContent", + } + ) + ), + NullableSearchResult = new SearchResult( + new SearchResult.User( + new UserResponse + { + Id = "id", + Username = "username", + Email = "email", + Phone = "phone", + CreatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000), + UpdatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000), + Address = new Address + { + Street = "street", + City = "city", + State = "state", + ZipCode = "zipCode", + Country = "country", + BuildingId = "buildingId", + TenantId = "tenantId", + }, + } + ) + ), + OptionalSearchResult = new SearchResult( + new SearchResult.User( + new UserResponse + { + Id = "id", + Username = "username", + Email = "email", + Phone = "phone", + CreatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000), + UpdatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000), + Address = new Address + { + Street = "street", + City = "city", + State = "state", + ZipCode = "zipCode", + Country = "country", + BuildingId = "buildingId", + TenantId = "tenantId", + }, + } + ) + ), + NullableArray = new List() { "nullableArray", "nullableArray" }, + OptionalArray = new List() { "optionalArray", "optionalArray" }, + OptionalNullableArray = new List() + { + "optionalNullableArray", + "optionalNullableArray", + }, + NullableListOfNullables = new List() + { + "nullableListOfNullables", + "nullableListOfNullables", + }, + NullableMapOfNullables = new Dictionary() + { + { + "nullableMapOfNullables", + new Address + { + Street = "street", + City = "city", + State = "state", + ZipCode = "zipCode", + Country = "country", + BuildingId = "buildingId", + TenantId = "tenantId", + } + }, + }, + NullableListOfUnions = new List() + { + new NotificationMethod( + new NotificationMethod.Email( + new EmailNotification + { + EmailAddress = "emailAddress", + Subject = "subject", + HtmlContent = "htmlContent", + } + ) + ), + new NotificationMethod( + new NotificationMethod.Email( + new EmailNotification + { + EmailAddress = "emailAddress", + Subject = "subject", + HtmlContent = "htmlContent", + } + ) + ), + }, + OptionalMapOfEnums = new Dictionary() + { + { "optionalMapOfEnums", UserRole.Admin }, + }, + } +); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request:** `ComplexProfile` + +
+
+
+
+ + +
+
+
+ +
client.NullableOptional.GetComplexProfileAsync(profileId) -> ComplexProfile +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Get a complex profile by ID +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```csharp +await client.NullableOptional.GetComplexProfileAsync("profileId"); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**profileId:** `string` + +
+
+
+
+ + +
+
+
+ +
client.NullableOptional.UpdateComplexProfileAsync(profileId, UpdateComplexProfileRequest { ... }) -> ComplexProfile +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Update complex profile to test nullable field updates +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```csharp +await client.NullableOptional.UpdateComplexProfileAsync( + "profileId", + new UpdateComplexProfileRequest + { + NullableRole = UserRole.Admin, + NullableStatus = UserStatus.Active, + NullableNotification = new NotificationMethod( + new NotificationMethod.Email( + new EmailNotification + { + EmailAddress = "emailAddress", + Subject = "subject", + HtmlContent = "htmlContent", + } + ) + ), + NullableSearchResult = new SearchResult( + new SearchResult.User( + new UserResponse + { + Id = "id", + Username = "username", + Email = "email", + Phone = "phone", + CreatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000), + UpdatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000), + Address = new Address + { + Street = "street", + City = "city", + State = "state", + ZipCode = "zipCode", + Country = "country", + BuildingId = "buildingId", + TenantId = "tenantId", + }, + } + ) + ), + NullableArray = new List() { "nullableArray", "nullableArray" }, + } +); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**profileId:** `string` + +
+
+ +
+
+ +**request:** `UpdateComplexProfileRequest` + +
+
+
+
+ + +
+
+
+ +
client.NullableOptional.TestDeserializationAsync(DeserializationTestRequest { ... }) -> DeserializationTestResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Test endpoint for validating null deserialization +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```csharp +await client.NullableOptional.TestDeserializationAsync( + new DeserializationTestRequest + { + RequiredString = "requiredString", + NullableString = "nullableString", + OptionalString = "optionalString", + OptionalNullableString = "optionalNullableString", + NullableEnum = UserRole.Admin, + OptionalEnum = UserStatus.Active, + NullableUnion = new NotificationMethod( + new NotificationMethod.Email( + new EmailNotification + { + EmailAddress = "emailAddress", + Subject = "subject", + HtmlContent = "htmlContent", + } + ) + ), + OptionalUnion = new SearchResult( + new SearchResult.User( + new UserResponse + { + Id = "id", + Username = "username", + Email = "email", + Phone = "phone", + CreatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000), + UpdatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000), + Address = new Address + { + Street = "street", + City = "city", + State = "state", + ZipCode = "zipCode", + Country = "country", + BuildingId = "buildingId", + TenantId = "tenantId", + }, + } + ) + ), + NullableList = new List() { "nullableList", "nullableList" }, + NullableMap = new Dictionary() { { "nullableMap", 1 } }, + NullableObject = new Address + { + Street = "street", + City = "city", + State = "state", + ZipCode = "zipCode", + Country = "country", + BuildingId = "buildingId", + TenantId = "tenantId", + }, + OptionalObject = new Organization + { + Id = "id", + Name = "name", + Domain = "domain", + EmployeeCount = 1, + }, + } +); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request:** `DeserializationTestRequest` + +
+
+
+
+ + +
+
+
+ +
client.NullableOptional.FilterByRoleAsync(FilterByRoleRequest { ... }) -> IEnumerable<UserResponse> +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Filter users by role with nullable enum +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```csharp +await client.NullableOptional.FilterByRoleAsync( + new FilterByRoleRequest + { + Role = UserRole.Admin, + Status = UserStatus.Active, + SecondaryRole = UserRole.Admin, + } +); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request:** `FilterByRoleRequest` + +
+
+
+
+ + +
+
+
+ +
client.NullableOptional.GetNotificationSettingsAsync(userId) -> NotificationMethod? +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Get notification settings which may be null +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```csharp +await client.NullableOptional.GetNotificationSettingsAsync("userId"); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**userId:** `string` + +
+
+
+
+ + +
+
+
+ +
client.NullableOptional.UpdateTagsAsync(userId, UpdateTagsRequest { ... }) -> IEnumerable<string> +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Update tags to test array handling +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```csharp +await client.NullableOptional.UpdateTagsAsync( + "userId", + new UpdateTagsRequest + { + Tags = new List() { "tags", "tags" }, + Categories = new List() { "categories", "categories" }, + Labels = new List() { "labels", "labels" }, + } +); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**userId:** `string` + +
+
+ +
+
+ +**request:** `UpdateTagsRequest` + +
+
+
+
+ + +
+
+
+ +
client.NullableOptional.GetSearchResultsAsync(SearchRequest { ... }) -> IEnumerable<SearchResult>? +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Get search results with nullable unions +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```csharp +await client.NullableOptional.GetSearchResultsAsync( + new SearchRequest + { + Query = "query", + Filters = new Dictionary() { { "filters", "filters" } }, + IncludeTypes = new List() { "includeTypes", "includeTypes" }, + } +); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request:** `SearchRequest` + +
+
+
+
+ + +
+
+
diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/snippet.json b/seed/csharp-sdk/nullable-optional/no-custom-config/snippet.json new file mode 100644 index 000000000000..18eee52c0aad --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/snippet.json @@ -0,0 +1,161 @@ +{ + "types": {}, + "endpoints": [ + { + "example_identifier": null, + "id": { + "path": "/api/users/{userId}", + "method": "GET", + "identifier_override": "endpoint_nullable-optional.getUser" + }, + "snippet": { + "type": "csharp", + "client": "using SeedNullableOptional;\n\nvar client = new SeedNullableOptionalClient();\nawait client.NullableOptional.GetUserAsync(\"userId\");\n" + } + }, + { + "example_identifier": null, + "id": { + "path": "/api/users", + "method": "POST", + "identifier_override": "endpoint_nullable-optional.createUser" + }, + "snippet": { + "type": "csharp", + "client": "using SeedNullableOptional;\n\nvar client = new SeedNullableOptionalClient();\nawait client.NullableOptional.CreateUserAsync(\n new CreateUserRequest\n {\n Username = \"username\",\n Email = \"email\",\n Phone = \"phone\",\n Address = new Address\n {\n Street = \"street\",\n City = \"city\",\n State = \"state\",\n ZipCode = \"zipCode\",\n Country = \"country\",\n BuildingId = \"buildingId\",\n TenantId = \"tenantId\",\n },\n }\n);\n" + } + }, + { + "example_identifier": null, + "id": { + "path": "/api/users/{userId}", + "method": "PATCH", + "identifier_override": "endpoint_nullable-optional.updateUser" + }, + "snippet": { + "type": "csharp", + "client": "using SeedNullableOptional;\n\nvar client = new SeedNullableOptionalClient();\nawait client.NullableOptional.UpdateUserAsync(\n \"userId\",\n new UpdateUserRequest\n {\n Username = \"username\",\n Email = \"email\",\n Phone = \"phone\",\n Address = new Address\n {\n Street = \"street\",\n City = \"city\",\n State = \"state\",\n ZipCode = \"zipCode\",\n Country = \"country\",\n BuildingId = \"buildingId\",\n TenantId = \"tenantId\",\n },\n }\n);\n" + } + }, + { + "example_identifier": null, + "id": { + "path": "/api/users", + "method": "GET", + "identifier_override": "endpoint_nullable-optional.listUsers" + }, + "snippet": { + "type": "csharp", + "client": "using SeedNullableOptional;\n\nvar client = new SeedNullableOptionalClient();\nawait client.NullableOptional.ListUsersAsync(\n new ListUsersRequest\n {\n Limit = 1,\n Offset = 1,\n IncludeDeleted = true,\n SortBy = \"sortBy\",\n }\n);\n" + } + }, + { + "example_identifier": null, + "id": { + "path": "/api/users/search", + "method": "GET", + "identifier_override": "endpoint_nullable-optional.searchUsers" + }, + "snippet": { + "type": "csharp", + "client": "using SeedNullableOptional;\n\nvar client = new SeedNullableOptionalClient();\nawait client.NullableOptional.SearchUsersAsync(\n new SearchUsersRequest\n {\n Query = \"query\",\n Department = \"department\",\n Role = \"role\",\n IsActive = true,\n }\n);\n" + } + }, + { + "example_identifier": null, + "id": { + "path": "/api/profiles/complex", + "method": "POST", + "identifier_override": "endpoint_nullable-optional.createComplexProfile" + }, + "snippet": { + "type": "csharp", + "client": "using SeedNullableOptional;\n\nvar client = new SeedNullableOptionalClient();\nawait client.NullableOptional.CreateComplexProfileAsync(\n new ComplexProfile\n {\n Id = \"id\",\n NullableRole = UserRole.Admin,\n OptionalRole = UserRole.Admin,\n OptionalNullableRole = UserRole.Admin,\n NullableStatus = UserStatus.Active,\n OptionalStatus = UserStatus.Active,\n OptionalNullableStatus = UserStatus.Active,\n NullableNotification = new NotificationMethod(\n new NotificationMethod.Email(\n new EmailNotification\n {\n EmailAddress = \"emailAddress\",\n Subject = \"subject\",\n HtmlContent = \"htmlContent\",\n }\n )\n ),\n OptionalNotification = new NotificationMethod(\n new NotificationMethod.Email(\n new EmailNotification\n {\n EmailAddress = \"emailAddress\",\n Subject = \"subject\",\n HtmlContent = \"htmlContent\",\n }\n )\n ),\n OptionalNullableNotification = new NotificationMethod(\n new NotificationMethod.Email(\n new EmailNotification\n {\n EmailAddress = \"emailAddress\",\n Subject = \"subject\",\n HtmlContent = \"htmlContent\",\n }\n )\n ),\n NullableSearchResult = new SearchResult(\n new SearchResult.User(\n new UserResponse\n {\n Id = \"id\",\n Username = \"username\",\n Email = \"email\",\n Phone = \"phone\",\n CreatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000),\n UpdatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000),\n Address = new Address\n {\n Street = \"street\",\n City = \"city\",\n State = \"state\",\n ZipCode = \"zipCode\",\n Country = \"country\",\n BuildingId = \"buildingId\",\n TenantId = \"tenantId\",\n },\n }\n )\n ),\n OptionalSearchResult = new SearchResult(\n new SearchResult.User(\n new UserResponse\n {\n Id = \"id\",\n Username = \"username\",\n Email = \"email\",\n Phone = \"phone\",\n CreatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000),\n UpdatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000),\n Address = new Address\n {\n Street = \"street\",\n City = \"city\",\n State = \"state\",\n ZipCode = \"zipCode\",\n Country = \"country\",\n BuildingId = \"buildingId\",\n TenantId = \"tenantId\",\n },\n }\n )\n ),\n NullableArray = new List() { \"nullableArray\", \"nullableArray\" },\n OptionalArray = new List() { \"optionalArray\", \"optionalArray\" },\n OptionalNullableArray = new List()\n {\n \"optionalNullableArray\",\n \"optionalNullableArray\",\n },\n NullableListOfNullables = new List()\n {\n \"nullableListOfNullables\",\n \"nullableListOfNullables\",\n },\n NullableMapOfNullables = new Dictionary()\n {\n {\n \"nullableMapOfNullables\",\n new Address\n {\n Street = \"street\",\n City = \"city\",\n State = \"state\",\n ZipCode = \"zipCode\",\n Country = \"country\",\n BuildingId = \"buildingId\",\n TenantId = \"tenantId\",\n }\n },\n },\n NullableListOfUnions = new List()\n {\n new NotificationMethod(\n new NotificationMethod.Email(\n new EmailNotification\n {\n EmailAddress = \"emailAddress\",\n Subject = \"subject\",\n HtmlContent = \"htmlContent\",\n }\n )\n ),\n new NotificationMethod(\n new NotificationMethod.Email(\n new EmailNotification\n {\n EmailAddress = \"emailAddress\",\n Subject = \"subject\",\n HtmlContent = \"htmlContent\",\n }\n )\n ),\n },\n OptionalMapOfEnums = new Dictionary()\n {\n { \"optionalMapOfEnums\", UserRole.Admin },\n },\n }\n);\n" + } + }, + { + "example_identifier": null, + "id": { + "path": "/api/profiles/complex/{profileId}", + "method": "GET", + "identifier_override": "endpoint_nullable-optional.getComplexProfile" + }, + "snippet": { + "type": "csharp", + "client": "using SeedNullableOptional;\n\nvar client = new SeedNullableOptionalClient();\nawait client.NullableOptional.GetComplexProfileAsync(\"profileId\");\n" + } + }, + { + "example_identifier": null, + "id": { + "path": "/api/profiles/complex/{profileId}", + "method": "PATCH", + "identifier_override": "endpoint_nullable-optional.updateComplexProfile" + }, + "snippet": { + "type": "csharp", + "client": "using SeedNullableOptional;\n\nvar client = new SeedNullableOptionalClient();\nawait client.NullableOptional.UpdateComplexProfileAsync(\n \"profileId\",\n new UpdateComplexProfileRequest\n {\n NullableRole = UserRole.Admin,\n NullableStatus = UserStatus.Active,\n NullableNotification = new NotificationMethod(\n new NotificationMethod.Email(\n new EmailNotification\n {\n EmailAddress = \"emailAddress\",\n Subject = \"subject\",\n HtmlContent = \"htmlContent\",\n }\n )\n ),\n NullableSearchResult = new SearchResult(\n new SearchResult.User(\n new UserResponse\n {\n Id = \"id\",\n Username = \"username\",\n Email = \"email\",\n Phone = \"phone\",\n CreatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000),\n UpdatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000),\n Address = new Address\n {\n Street = \"street\",\n City = \"city\",\n State = \"state\",\n ZipCode = \"zipCode\",\n Country = \"country\",\n BuildingId = \"buildingId\",\n TenantId = \"tenantId\",\n },\n }\n )\n ),\n NullableArray = new List() { \"nullableArray\", \"nullableArray\" },\n }\n);\n" + } + }, + { + "example_identifier": null, + "id": { + "path": "/api/test/deserialization", + "method": "POST", + "identifier_override": "endpoint_nullable-optional.testDeserialization" + }, + "snippet": { + "type": "csharp", + "client": "using SeedNullableOptional;\n\nvar client = new SeedNullableOptionalClient();\nawait client.NullableOptional.TestDeserializationAsync(\n new DeserializationTestRequest\n {\n RequiredString = \"requiredString\",\n NullableString = \"nullableString\",\n OptionalString = \"optionalString\",\n OptionalNullableString = \"optionalNullableString\",\n NullableEnum = UserRole.Admin,\n OptionalEnum = UserStatus.Active,\n NullableUnion = new NotificationMethod(\n new NotificationMethod.Email(\n new EmailNotification\n {\n EmailAddress = \"emailAddress\",\n Subject = \"subject\",\n HtmlContent = \"htmlContent\",\n }\n )\n ),\n OptionalUnion = new SearchResult(\n new SearchResult.User(\n new UserResponse\n {\n Id = \"id\",\n Username = \"username\",\n Email = \"email\",\n Phone = \"phone\",\n CreatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000),\n UpdatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000),\n Address = new Address\n {\n Street = \"street\",\n City = \"city\",\n State = \"state\",\n ZipCode = \"zipCode\",\n Country = \"country\",\n BuildingId = \"buildingId\",\n TenantId = \"tenantId\",\n },\n }\n )\n ),\n NullableList = new List() { \"nullableList\", \"nullableList\" },\n NullableMap = new Dictionary() { { \"nullableMap\", 1 } },\n NullableObject = new Address\n {\n Street = \"street\",\n City = \"city\",\n State = \"state\",\n ZipCode = \"zipCode\",\n Country = \"country\",\n BuildingId = \"buildingId\",\n TenantId = \"tenantId\",\n },\n OptionalObject = new Organization\n {\n Id = \"id\",\n Name = \"name\",\n Domain = \"domain\",\n EmployeeCount = 1,\n },\n }\n);\n" + } + }, + { + "example_identifier": null, + "id": { + "path": "/api/users/filter", + "method": "GET", + "identifier_override": "endpoint_nullable-optional.filterByRole" + }, + "snippet": { + "type": "csharp", + "client": "using SeedNullableOptional;\n\nvar client = new SeedNullableOptionalClient();\nawait client.NullableOptional.FilterByRoleAsync(\n new FilterByRoleRequest\n {\n Role = UserRole.Admin,\n Status = UserStatus.Active,\n SecondaryRole = UserRole.Admin,\n }\n);\n" + } + }, + { + "example_identifier": null, + "id": { + "path": "/api/users/{userId}/notifications", + "method": "GET", + "identifier_override": "endpoint_nullable-optional.getNotificationSettings" + }, + "snippet": { + "type": "csharp", + "client": "using SeedNullableOptional;\n\nvar client = new SeedNullableOptionalClient();\nawait client.NullableOptional.GetNotificationSettingsAsync(\"userId\");\n" + } + }, + { + "example_identifier": null, + "id": { + "path": "/api/users/{userId}/tags", + "method": "PUT", + "identifier_override": "endpoint_nullable-optional.updateTags" + }, + "snippet": { + "type": "csharp", + "client": "using SeedNullableOptional;\n\nvar client = new SeedNullableOptionalClient();\nawait client.NullableOptional.UpdateTagsAsync(\n \"userId\",\n new UpdateTagsRequest\n {\n Tags = new List() { \"tags\", \"tags\" },\n Categories = new List() { \"categories\", \"categories\" },\n Labels = new List() { \"labels\", \"labels\" },\n }\n);\n" + } + }, + { + "example_identifier": null, + "id": { + "path": "/api/search", + "method": "POST", + "identifier_override": "endpoint_nullable-optional.getSearchResults" + }, + "snippet": { + "type": "csharp", + "client": "using SeedNullableOptional;\n\nvar client = new SeedNullableOptionalClient();\nawait client.NullableOptional.GetSearchResultsAsync(\n new SearchRequest\n {\n Query = \"query\",\n Filters = new Dictionary() { { \"filters\", \"filters\" } },\n IncludeTypes = new List() { \"includeTypes\", \"includeTypes\" },\n }\n);\n" + } + } + ] +} \ No newline at end of file diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedApi.DynamicSnippets/Example0.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedApi.DynamicSnippets/Example0.cs new file mode 100644 index 000000000000..8b8e527f306e --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedApi.DynamicSnippets/Example0.cs @@ -0,0 +1,19 @@ +using SeedNullableOptional; + +namespace Usage; + +public class Example0 +{ + public async Task Do() { + var client = new SeedNullableOptionalClient( + clientOptions: new ClientOptions { + BaseUrl = "https://api.fern.com" + } + ); + + await client.NullableOptional.GetUserAsync( + "userId" + ); + } + +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedApi.DynamicSnippets/Example1.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedApi.DynamicSnippets/Example1.cs new file mode 100644 index 000000000000..6ee9bcfb8dc5 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedApi.DynamicSnippets/Example1.cs @@ -0,0 +1,32 @@ +using SeedNullableOptional; + +namespace Usage; + +public class Example1 +{ + public async Task Do() { + var client = new SeedNullableOptionalClient( + clientOptions: new ClientOptions { + BaseUrl = "https://api.fern.com" + } + ); + + await client.NullableOptional.CreateUserAsync( + new CreateUserRequest { + Username = "username", + Email = "email", + Phone = "phone", + Address = new Address { + Street = "street", + City = "city", + State = "state", + ZipCode = "zipCode", + Country = "country", + BuildingId = "buildingId", + TenantId = "tenantId" + } + } + ); + } + +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedApi.DynamicSnippets/Example10.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedApi.DynamicSnippets/Example10.cs new file mode 100644 index 000000000000..86bb61560e75 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedApi.DynamicSnippets/Example10.cs @@ -0,0 +1,19 @@ +using SeedNullableOptional; + +namespace Usage; + +public class Example10 +{ + public async Task Do() { + var client = new SeedNullableOptionalClient( + clientOptions: new ClientOptions { + BaseUrl = "https://api.fern.com" + } + ); + + await client.NullableOptional.GetNotificationSettingsAsync( + "userId" + ); + } + +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedApi.DynamicSnippets/Example11.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedApi.DynamicSnippets/Example11.cs new file mode 100644 index 000000000000..6f9b15b3d14b --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedApi.DynamicSnippets/Example11.cs @@ -0,0 +1,36 @@ +using SeedNullableOptional; + +namespace Usage; + +public class Example11 +{ + public async Task Do() { + var client = new SeedNullableOptionalClient( + clientOptions: new ClientOptions { + BaseUrl = "https://api.fern.com" + } + ); + + await client.NullableOptional.UpdateTagsAsync( + "userId", + new UpdateTagsRequest { + Tags = new List(){ + "tags", + "tags", + } + , + Categories = new List(){ + "categories", + "categories", + } + , + Labels = new List(){ + "labels", + "labels", + } + + } + ); + } + +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedApi.DynamicSnippets/Example12.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedApi.DynamicSnippets/Example12.cs new file mode 100644 index 000000000000..ae6edcebde62 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedApi.DynamicSnippets/Example12.cs @@ -0,0 +1,30 @@ +using SeedNullableOptional; + +namespace Usage; + +public class Example12 +{ + public async Task Do() { + var client = new SeedNullableOptionalClient( + clientOptions: new ClientOptions { + BaseUrl = "https://api.fern.com" + } + ); + + await client.NullableOptional.GetSearchResultsAsync( + new SearchRequest { + Query = "query", + Filters = new Dictionary(){ + ["filters"] = "filters", + } + , + IncludeTypes = new List(){ + "includeTypes", + "includeTypes", + } + + } + ); + } + +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedApi.DynamicSnippets/Example2.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedApi.DynamicSnippets/Example2.cs new file mode 100644 index 000000000000..1f8c9b7b44f3 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedApi.DynamicSnippets/Example2.cs @@ -0,0 +1,33 @@ +using SeedNullableOptional; + +namespace Usage; + +public class Example2 +{ + public async Task Do() { + var client = new SeedNullableOptionalClient( + clientOptions: new ClientOptions { + BaseUrl = "https://api.fern.com" + } + ); + + await client.NullableOptional.UpdateUserAsync( + "userId", + new UpdateUserRequest { + Username = "username", + Email = "email", + Phone = "phone", + Address = new Address { + Street = "street", + City = "city", + State = "state", + ZipCode = "zipCode", + Country = "country", + BuildingId = "buildingId", + TenantId = "tenantId" + } + } + ); + } + +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedApi.DynamicSnippets/Example3.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedApi.DynamicSnippets/Example3.cs new file mode 100644 index 000000000000..f2477802db33 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedApi.DynamicSnippets/Example3.cs @@ -0,0 +1,24 @@ +using SeedNullableOptional; + +namespace Usage; + +public class Example3 +{ + public async Task Do() { + var client = new SeedNullableOptionalClient( + clientOptions: new ClientOptions { + BaseUrl = "https://api.fern.com" + } + ); + + await client.NullableOptional.ListUsersAsync( + new ListUsersRequest { + Limit = 1, + Offset = 1, + IncludeDeleted = true, + SortBy = "sortBy" + } + ); + } + +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedApi.DynamicSnippets/Example4.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedApi.DynamicSnippets/Example4.cs new file mode 100644 index 000000000000..bf14271d8f3d --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedApi.DynamicSnippets/Example4.cs @@ -0,0 +1,24 @@ +using SeedNullableOptional; + +namespace Usage; + +public class Example4 +{ + public async Task Do() { + var client = new SeedNullableOptionalClient( + clientOptions: new ClientOptions { + BaseUrl = "https://api.fern.com" + } + ); + + await client.NullableOptional.SearchUsersAsync( + new SearchUsersRequest { + Query = "query", + Department = "department", + Role = "role", + IsActive = true + } + ); + } + +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedApi.DynamicSnippets/Example5.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedApi.DynamicSnippets/Example5.cs new file mode 100644 index 000000000000..168bbbfa9bde --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedApi.DynamicSnippets/Example5.cs @@ -0,0 +1,140 @@ +using SeedNullableOptional; +using System.Globalization; + +namespace Usage; + +public class Example5 +{ + public async Task Do() { + var client = new SeedNullableOptionalClient( + clientOptions: new ClientOptions { + BaseUrl = "https://api.fern.com" + } + ); + + await client.NullableOptional.CreateComplexProfileAsync( + new ComplexProfile { + Id = "id", + NullableRole = UserRole.Admin, + OptionalRole = UserRole.Admin, + OptionalNullableRole = UserRole.Admin, + NullableStatus = UserStatus.Active, + OptionalStatus = UserStatus.Active, + OptionalNullableStatus = UserStatus.Active, + NullableNotification = new NotificationMethod( + new EmailNotification { + EmailAddress = "emailAddress", + Subject = "subject", + HtmlContent = "htmlContent" + } + ), + OptionalNotification = new NotificationMethod( + new EmailNotification { + EmailAddress = "emailAddress", + Subject = "subject", + HtmlContent = "htmlContent" + } + ), + OptionalNullableNotification = new NotificationMethod( + new EmailNotification { + EmailAddress = "emailAddress", + Subject = "subject", + HtmlContent = "htmlContent" + } + ), + NullableSearchResult = new SearchResult( + new UserResponse { + Id = "id", + Username = "username", + Email = "email", + Phone = "phone", + CreatedAt = DateTime.Parse("2024-01-15T09:30:00Z", null, DateTimeStyles.AdjustToUniversal), + UpdatedAt = DateTime.Parse("2024-01-15T09:30:00Z", null, DateTimeStyles.AdjustToUniversal), + Address = new Address { + Street = "street", + City = "city", + State = "state", + ZipCode = "zipCode", + Country = "country", + BuildingId = "buildingId", + TenantId = "tenantId" + } + } + ), + OptionalSearchResult = new SearchResult( + new UserResponse { + Id = "id", + Username = "username", + Email = "email", + Phone = "phone", + CreatedAt = DateTime.Parse("2024-01-15T09:30:00Z", null, DateTimeStyles.AdjustToUniversal), + UpdatedAt = DateTime.Parse("2024-01-15T09:30:00Z", null, DateTimeStyles.AdjustToUniversal), + Address = new Address { + Street = "street", + City = "city", + State = "state", + ZipCode = "zipCode", + Country = "country", + BuildingId = "buildingId", + TenantId = "tenantId" + } + } + ), + NullableArray = new List(){ + "nullableArray", + "nullableArray", + } + , + OptionalArray = new List(){ + "optionalArray", + "optionalArray", + } + , + OptionalNullableArray = new List(){ + "optionalNullableArray", + "optionalNullableArray", + } + , + NullableListOfNullables = new List(){ + "nullableListOfNullables", + "nullableListOfNullables", + } + , + NullableMapOfNullables = new Dictionary(){ + ["nullableMapOfNullables"] = new Address { + Street = "street", + City = "city", + State = "state", + ZipCode = "zipCode", + Country = "country", + BuildingId = "buildingId", + TenantId = "tenantId" + }, + } + , + NullableListOfUnions = new List(){ + new NotificationMethod( + new EmailNotification { + EmailAddress = "emailAddress", + Subject = "subject", + HtmlContent = "htmlContent" + } + ), + new NotificationMethod( + new EmailNotification { + EmailAddress = "emailAddress", + Subject = "subject", + HtmlContent = "htmlContent" + } + ), + } + , + OptionalMapOfEnums = new Dictionary(){ + ["optionalMapOfEnums"] = UserRole.Admin, + } + + } + ); + } + +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedApi.DynamicSnippets/Example6.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedApi.DynamicSnippets/Example6.cs new file mode 100644 index 000000000000..d6483e26e22c --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedApi.DynamicSnippets/Example6.cs @@ -0,0 +1,19 @@ +using SeedNullableOptional; + +namespace Usage; + +public class Example6 +{ + public async Task Do() { + var client = new SeedNullableOptionalClient( + clientOptions: new ClientOptions { + BaseUrl = "https://api.fern.com" + } + ); + + await client.NullableOptional.GetComplexProfileAsync( + "profileId" + ); + } + +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedApi.DynamicSnippets/Example7.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedApi.DynamicSnippets/Example7.cs new file mode 100644 index 000000000000..e756a75c1e12 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedApi.DynamicSnippets/Example7.cs @@ -0,0 +1,55 @@ +using SeedNullableOptional; +using System.Globalization; + +namespace Usage; + +public class Example7 +{ + public async Task Do() { + var client = new SeedNullableOptionalClient( + clientOptions: new ClientOptions { + BaseUrl = "https://api.fern.com" + } + ); + + await client.NullableOptional.UpdateComplexProfileAsync( + "profileId", + new UpdateComplexProfileRequest { + NullableRole = UserRole.Admin, + NullableStatus = UserStatus.Active, + NullableNotification = new NotificationMethod( + new EmailNotification { + EmailAddress = "emailAddress", + Subject = "subject", + HtmlContent = "htmlContent" + } + ), + NullableSearchResult = new SearchResult( + new UserResponse { + Id = "id", + Username = "username", + Email = "email", + Phone = "phone", + CreatedAt = DateTime.Parse("2024-01-15T09:30:00Z", null, DateTimeStyles.AdjustToUniversal), + UpdatedAt = DateTime.Parse("2024-01-15T09:30:00Z", null, DateTimeStyles.AdjustToUniversal), + Address = new Address { + Street = "street", + City = "city", + State = "state", + ZipCode = "zipCode", + Country = "country", + BuildingId = "buildingId", + TenantId = "tenantId" + } + } + ), + NullableArray = new List(){ + "nullableArray", + "nullableArray", + } + + } + ); + } + +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedApi.DynamicSnippets/Example8.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedApi.DynamicSnippets/Example8.cs new file mode 100644 index 000000000000..74e14851bc57 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedApi.DynamicSnippets/Example8.cs @@ -0,0 +1,77 @@ +using SeedNullableOptional; +using System.Globalization; + +namespace Usage; + +public class Example8 +{ + public async Task Do() { + var client = new SeedNullableOptionalClient( + clientOptions: new ClientOptions { + BaseUrl = "https://api.fern.com" + } + ); + + await client.NullableOptional.TestDeserializationAsync( + new DeserializationTestRequest { + RequiredString = "requiredString", + NullableString = "nullableString", + OptionalString = "optionalString", + OptionalNullableString = "optionalNullableString", + NullableEnum = UserRole.Admin, + OptionalEnum = UserStatus.Active, + NullableUnion = new NotificationMethod( + new EmailNotification { + EmailAddress = "emailAddress", + Subject = "subject", + HtmlContent = "htmlContent" + } + ), + OptionalUnion = new SearchResult( + new UserResponse { + Id = "id", + Username = "username", + Email = "email", + Phone = "phone", + CreatedAt = DateTime.Parse("2024-01-15T09:30:00Z", null, DateTimeStyles.AdjustToUniversal), + UpdatedAt = DateTime.Parse("2024-01-15T09:30:00Z", null, DateTimeStyles.AdjustToUniversal), + Address = new Address { + Street = "street", + City = "city", + State = "state", + ZipCode = "zipCode", + Country = "country", + BuildingId = "buildingId", + TenantId = "tenantId" + } + } + ), + NullableList = new List(){ + "nullableList", + "nullableList", + } + , + NullableMap = new Dictionary(){ + ["nullableMap"] = 1, + } + , + NullableObject = new Address { + Street = "street", + City = "city", + State = "state", + ZipCode = "zipCode", + Country = "country", + BuildingId = "buildingId", + TenantId = "tenantId" + }, + OptionalObject = new Organization { + Id = "id", + Name = "name", + Domain = "domain", + EmployeeCount = 1 + } + } + ); + } + +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedApi.DynamicSnippets/Example9.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedApi.DynamicSnippets/Example9.cs new file mode 100644 index 000000000000..c4cf71d41bd5 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedApi.DynamicSnippets/Example9.cs @@ -0,0 +1,23 @@ +using SeedNullableOptional; + +namespace Usage; + +public class Example9 +{ + public async Task Do() { + var client = new SeedNullableOptionalClient( + clientOptions: new ClientOptions { + BaseUrl = "https://api.fern.com" + } + ); + + await client.NullableOptional.FilterByRoleAsync( + new FilterByRoleRequest { + Role = UserRole.Admin, + Status = UserStatus.Active, + SecondaryRole = UserRole.Admin + } + ); + } + +} diff --git a/seed/csharp-sdk/nullable/src/SeedApi.DynamicSnippets/SeedApi.DynamicSnippets.csproj b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedApi.DynamicSnippets/SeedApi.DynamicSnippets.csproj similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedApi.DynamicSnippets/SeedApi.DynamicSnippets.csproj rename to seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedApi.DynamicSnippets/SeedApi.DynamicSnippets.csproj diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Core/Json/AdditionalPropertiesTests.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Core/Json/AdditionalPropertiesTests.cs new file mode 100644 index 000000000000..fa30d13d80b9 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Core/Json/AdditionalPropertiesTests.cs @@ -0,0 +1,365 @@ +using global::System.Text.Json; +using global::System.Text.Json.Serialization; +using NUnit.Framework; +using SeedNullableOptional.Core; + +namespace SeedNullableOptional.Test.Core.Json; + +[TestFixture] +public class AdditionalPropertiesTests +{ + [Test] + public void Record_OnDeserialized_ShouldPopulateAdditionalProperties() + { + // Arrange + const string json = """ + { + "id": "1", + "category": "fiction", + "title": "The Hobbit" + } + """; + + // Act + var record = JsonUtils.Deserialize(json); + + // Assert + Assert.That(record, Is.Not.Null); + Assert.Multiple(() => + { + Assert.That(record.Id, Is.EqualTo("1")); + Assert.That(record.AdditionalProperties["category"].GetString(), Is.EqualTo("fiction")); + Assert.That(record.AdditionalProperties["title"].GetString(), Is.EqualTo("The Hobbit")); + }); + } + + [Test] + public void RecordWithWriteableAdditionalProperties_OnSerialization_ShouldIncludeAdditionalProperties() + { + // Arrange + var record = new WriteableRecord + { + Id = "1", + AdditionalProperties = { ["category"] = "fiction", ["title"] = "The Hobbit" }, + }; + + // Act + var json = JsonUtils.Serialize(record); + var deserializedRecord = JsonUtils.Deserialize(json); + + // Assert + Assert.That(deserializedRecord, Is.Not.Null); + Assert.Multiple(() => + { + Assert.That(deserializedRecord.Id, Is.EqualTo("1")); + Assert.That( + deserializedRecord.AdditionalProperties["category"], + Is.InstanceOf() + ); + Assert.That( + ((JsonElement)deserializedRecord.AdditionalProperties["category"]!).GetString(), + Is.EqualTo("fiction") + ); + Assert.That( + deserializedRecord.AdditionalProperties["title"], + Is.InstanceOf() + ); + Assert.That( + ((JsonElement)deserializedRecord.AdditionalProperties["title"]!).GetString(), + Is.EqualTo("The Hobbit") + ); + }); + } + + [Test] + public void ReadOnlyAdditionalProperties_ShouldRetrieveValuesCorrectly() + { + // Arrange + var extensionData = new Dictionary + { + ["key1"] = JsonUtils.SerializeToElement("value1"), + ["key2"] = JsonUtils.SerializeToElement(123), + }; + var readOnlyProps = new ReadOnlyAdditionalProperties(); + readOnlyProps.CopyFromExtensionData(extensionData); + + // Act & Assert + Assert.That(readOnlyProps["key1"].GetString(), Is.EqualTo("value1")); + Assert.That(readOnlyProps["key2"].GetInt32(), Is.EqualTo(123)); + } + + [Test] + public void AdditionalProperties_ShouldBehaveAsDictionary() + { + // Arrange + var additionalProps = new AdditionalProperties { ["key1"] = "value1", ["key2"] = 123 }; + + // Act + additionalProps["key3"] = true; + + // Assert + Assert.Multiple(() => + { + Assert.That(additionalProps["key1"], Is.EqualTo("value1")); + Assert.That(additionalProps["key2"], Is.EqualTo(123)); + Assert.That((bool)additionalProps["key3"]!, Is.True); + Assert.That(additionalProps.Count, Is.EqualTo(3)); + }); + } + + [Test] + public void AdditionalProperties_ToJsonObject_ShouldSerializeCorrectly() + { + // Arrange + var additionalProps = new AdditionalProperties { ["key1"] = "value1", ["key2"] = 123 }; + + // Act + var jsonObject = additionalProps.ToJsonObject(); + + Assert.Multiple(() => + { + // Assert + Assert.That(jsonObject["key1"]!.GetValue(), Is.EqualTo("value1")); + Assert.That(jsonObject["key2"]!.GetValue(), Is.EqualTo(123)); + }); + } + + [Test] + public void AdditionalProperties_MixReadAndWrite_ShouldOverwriteDeserializedProperty() + { + // Arrange + const string json = """ + { + "id": "1", + "category": "fiction", + "title": "The Hobbit" + } + """; + var record = JsonUtils.Deserialize(json); + + // Act + record.AdditionalProperties["category"] = "non-fiction"; + + // Assert + Assert.Multiple(() => + { + Assert.That(record, Is.Not.Null); + Assert.That(record.Id, Is.EqualTo("1")); + Assert.That(record.AdditionalProperties["category"], Is.EqualTo("non-fiction")); + Assert.That(record.AdditionalProperties["title"], Is.InstanceOf()); + Assert.That( + ((JsonElement)record.AdditionalProperties["title"]!).GetString(), + Is.EqualTo("The Hobbit") + ); + }); + } + + [Test] + public void RecordWithReadonlyAdditionalPropertiesInts_OnDeserialized_ShouldPopulateAdditionalProperties() + { + // Arrange + const string json = """ + { + "extra1": 42, + "extra2": 99 + } + """; + + // Act + var record = JsonUtils.Deserialize(json); + + // Assert + Assert.That(record, Is.Not.Null); + Assert.Multiple(() => + { + Assert.That(record.AdditionalProperties["extra1"], Is.EqualTo(42)); + Assert.That(record.AdditionalProperties["extra2"], Is.EqualTo(99)); + }); + } + + [Test] + public void RecordWithAdditionalPropertiesInts_OnSerialization_ShouldIncludeAdditionalProperties() + { + // Arrange + var record = new WriteableRecordWithInts + { + AdditionalProperties = { ["extra1"] = 42, ["extra2"] = 99 }, + }; + + // Act + var json = JsonUtils.Serialize(record); + var deserializedRecord = JsonUtils.Deserialize(json); + + // Assert + Assert.That(deserializedRecord, Is.Not.Null); + Assert.Multiple(() => + { + Assert.That(deserializedRecord.AdditionalProperties["extra1"], Is.EqualTo(42)); + Assert.That(deserializedRecord.AdditionalProperties["extra2"], Is.EqualTo(99)); + }); + } + + [Test] + public void RecordWithReadonlyAdditionalPropertiesDictionaries_OnDeserialized_ShouldPopulateAdditionalProperties() + { + // Arrange + const string json = """ + { + "extra1": { "key1": true, "key2": false }, + "extra2": { "key3": true } + } + """; + + // Act + var record = JsonUtils.Deserialize(json); + + // Assert + Assert.That(record, Is.Not.Null); + Assert.Multiple(() => + { + Assert.That(record.AdditionalProperties["extra1"]["key1"], Is.True); + Assert.That(record.AdditionalProperties["extra1"]["key2"], Is.False); + Assert.That(record.AdditionalProperties["extra2"]["key3"], Is.True); + }); + } + + [Test] + public void RecordWithAdditionalPropertiesDictionaries_OnSerialization_ShouldIncludeAdditionalProperties() + { + // Arrange + var record = new WriteableRecordWithDictionaries + { + AdditionalProperties = + { + ["extra1"] = new Dictionary { { "key1", true }, { "key2", false } }, + ["extra2"] = new Dictionary { { "key3", true } }, + }, + }; + + // Act + var json = JsonUtils.Serialize(record); + var deserializedRecord = JsonUtils.Deserialize(json); + + // Assert + Assert.That(deserializedRecord, Is.Not.Null); + Assert.Multiple(() => + { + Assert.That(deserializedRecord.AdditionalProperties["extra1"]["key1"], Is.True); + Assert.That(deserializedRecord.AdditionalProperties["extra1"]["key2"], Is.False); + Assert.That(deserializedRecord.AdditionalProperties["extra2"]["key3"], Is.True); + }); + } + + private record Record : IJsonOnDeserialized + { + [JsonPropertyName("id")] + public required string Id { get; set; } + + [JsonExtensionData] + private readonly IDictionary _extensionData = + new Dictionary(); + + [JsonIgnore] + public ReadOnlyAdditionalProperties AdditionalProperties { get; } = new(); + + void IJsonOnDeserialized.OnDeserialized() + { + AdditionalProperties.CopyFromExtensionData(_extensionData); + } + } + + private record WriteableRecord : IJsonOnDeserialized, IJsonOnSerializing + { + [JsonPropertyName("id")] + public required string Id { get; set; } + + [JsonExtensionData] + private readonly IDictionary _extensionData = + new Dictionary(); + + [JsonIgnore] + public AdditionalProperties AdditionalProperties { get; set; } = new(); + + void IJsonOnDeserialized.OnDeserialized() + { + AdditionalProperties.CopyFromExtensionData(_extensionData); + } + + void IJsonOnSerializing.OnSerializing() + { + AdditionalProperties.CopyToExtensionData(_extensionData); + } + } + + private record RecordWithInts : IJsonOnDeserialized + { + [JsonExtensionData] + private readonly IDictionary _extensionData = + new Dictionary(); + + [JsonIgnore] + public ReadOnlyAdditionalProperties AdditionalProperties { get; } = new(); + + void IJsonOnDeserialized.OnDeserialized() + { + AdditionalProperties.CopyFromExtensionData(_extensionData); + } + } + + private record WriteableRecordWithInts : IJsonOnDeserialized, IJsonOnSerializing + { + [JsonExtensionData] + private readonly IDictionary _extensionData = + new Dictionary(); + + [JsonIgnore] + public AdditionalProperties AdditionalProperties { get; } = new(); + + void IJsonOnDeserialized.OnDeserialized() + { + AdditionalProperties.CopyFromExtensionData(_extensionData); + } + + void IJsonOnSerializing.OnSerializing() + { + AdditionalProperties.CopyToExtensionData(_extensionData); + } + } + + private record RecordWithDictionaries : IJsonOnDeserialized + { + [JsonExtensionData] + private readonly IDictionary _extensionData = + new Dictionary(); + + [JsonIgnore] + public ReadOnlyAdditionalProperties< + Dictionary + > AdditionalProperties { get; } = new(); + + void IJsonOnDeserialized.OnDeserialized() + { + AdditionalProperties.CopyFromExtensionData(_extensionData); + } + } + + private record WriteableRecordWithDictionaries : IJsonOnDeserialized, IJsonOnSerializing + { + [JsonExtensionData] + private readonly IDictionary _extensionData = + new Dictionary(); + + [JsonIgnore] + public AdditionalProperties> AdditionalProperties { get; } = new(); + + void IJsonOnDeserialized.OnDeserialized() + { + AdditionalProperties.CopyFromExtensionData(_extensionData); + } + + void IJsonOnSerializing.OnSerializing() + { + AdditionalProperties.CopyToExtensionData(_extensionData); + } + } +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Core/Json/DateOnlyJsonTests.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Core/Json/DateOnlyJsonTests.cs new file mode 100644 index 000000000000..73850b070558 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Core/Json/DateOnlyJsonTests.cs @@ -0,0 +1,76 @@ +using NUnit.Framework; +using SeedNullableOptional.Core; + +namespace SeedNullableOptional.Test.Core.Json; + +[TestFixture] +public class DateOnlyJsonTests +{ + [Test] + public void SerializeDateOnly_ShouldMatchExpectedFormat() + { + (DateOnly dateOnly, string expected)[] testCases = + [ + (new DateOnly(2023, 10, 5), "\"2023-10-05\""), + (new DateOnly(2023, 1, 1), "\"2023-01-01\""), + (new DateOnly(2023, 12, 31), "\"2023-12-31\""), + (new DateOnly(2023, 6, 15), "\"2023-06-15\""), + (new DateOnly(2023, 3, 10), "\"2023-03-10\""), + ]; + foreach (var (dateOnly, expected) in testCases) + { + var json = JsonUtils.Serialize(dateOnly); + Assert.That(json, Is.EqualTo(expected)); + } + } + + [Test] + public void DeserializeDateOnly_ShouldMatchExpectedDateOnly() + { + (DateOnly expected, string json)[] testCases = + [ + (new DateOnly(2023, 10, 5), "\"2023-10-05\""), + (new DateOnly(2023, 1, 1), "\"2023-01-01\""), + (new DateOnly(2023, 12, 31), "\"2023-12-31\""), + (new DateOnly(2023, 6, 15), "\"2023-06-15\""), + (new DateOnly(2023, 3, 10), "\"2023-03-10\""), + ]; + + foreach (var (expected, json) in testCases) + { + var dateOnly = JsonUtils.Deserialize(json); + Assert.That(dateOnly, Is.EqualTo(expected)); + } + } + + [Test] + public void SerializeNullableDateOnly_ShouldMatchExpectedFormat() + { + (DateOnly? dateOnly, string expected)[] testCases = + [ + (new DateOnly(2023, 10, 5), "\"2023-10-05\""), + (null, "null"), + ]; + foreach (var (dateOnly, expected) in testCases) + { + var json = JsonUtils.Serialize(dateOnly); + Assert.That(json, Is.EqualTo(expected)); + } + } + + [Test] + public void DeserializeNullableDateOnly_ShouldMatchExpectedDateOnly() + { + (DateOnly? expected, string json)[] testCases = + [ + (new DateOnly(2023, 10, 5), "\"2023-10-05\""), + (null, "null"), + ]; + + foreach (var (expected, json) in testCases) + { + var dateOnly = JsonUtils.Deserialize(json); + Assert.That(dateOnly, Is.EqualTo(expected)); + } + } +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Core/Json/DateTimeJsonTests.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Core/Json/DateTimeJsonTests.cs new file mode 100644 index 000000000000..b0a91ad83421 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Core/Json/DateTimeJsonTests.cs @@ -0,0 +1,110 @@ +using NUnit.Framework; +using SeedNullableOptional.Core; + +namespace SeedNullableOptional.Test.Core.Json; + +[TestFixture] +public class DateTimeJsonTests +{ + [Test] + public void SerializeDateTime_ShouldMatchExpectedFormat() + { + (DateTime dateTime, string expected)[] testCases = + [ + ( + new DateTime(2023, 10, 5, 14, 30, 0, DateTimeKind.Utc), + "\"2023-10-05T14:30:00.000Z\"" + ), + (new DateTime(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc), "\"2023-01-01T00:00:00.000Z\""), + ( + new DateTime(2023, 12, 31, 23, 59, 59, DateTimeKind.Utc), + "\"2023-12-31T23:59:59.000Z\"" + ), + (new DateTime(2023, 6, 15, 12, 0, 0, DateTimeKind.Utc), "\"2023-06-15T12:00:00.000Z\""), + ( + new DateTime(2023, 3, 10, 8, 45, 30, DateTimeKind.Utc), + "\"2023-03-10T08:45:30.000Z\"" + ), + ( + new DateTime(2023, 3, 10, 8, 45, 30, 123, DateTimeKind.Utc), + "\"2023-03-10T08:45:30.123Z\"" + ), + ]; + foreach (var (dateTime, expected) in testCases) + { + var json = JsonUtils.Serialize(dateTime); + Assert.That(json, Is.EqualTo(expected)); + } + } + + [Test] + public void DeserializeDateTime_ShouldMatchExpectedDateTime() + { + (DateTime expected, string json)[] testCases = + [ + ( + new DateTime(2023, 10, 5, 14, 30, 0, DateTimeKind.Utc), + "\"2023-10-05T14:30:00.000Z\"" + ), + (new DateTime(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc), "\"2023-01-01T00:00:00.000Z\""), + ( + new DateTime(2023, 12, 31, 23, 59, 59, DateTimeKind.Utc), + "\"2023-12-31T23:59:59.000Z\"" + ), + (new DateTime(2023, 6, 15, 12, 0, 0, DateTimeKind.Utc), "\"2023-06-15T12:00:00.000Z\""), + ( + new DateTime(2023, 3, 10, 8, 45, 30, DateTimeKind.Utc), + "\"2023-03-10T08:45:30.000Z\"" + ), + (new DateTime(2023, 3, 10, 8, 45, 30, DateTimeKind.Utc), "\"2023-03-10T08:45:30Z\""), + ( + new DateTime(2023, 3, 10, 8, 45, 30, 123, DateTimeKind.Utc), + "\"2023-03-10T08:45:30.123Z\"" + ), + ]; + + foreach (var (expected, json) in testCases) + { + var dateTime = JsonUtils.Deserialize(json); + Assert.That(dateTime, Is.EqualTo(expected)); + } + } + + [Test] + public void SerializeNullableDateTime_ShouldMatchExpectedFormat() + { + (DateTime? expected, string json)[] testCases = + [ + ( + new DateTime(2023, 10, 5, 14, 30, 0, DateTimeKind.Utc), + "\"2023-10-05T14:30:00.000Z\"" + ), + (null, "null"), + ]; + + foreach (var (expected, json) in testCases) + { + var dateTime = JsonUtils.Deserialize(json); + Assert.That(dateTime, Is.EqualTo(expected)); + } + } + + [Test] + public void DeserializeNullableDateTime_ShouldMatchExpectedDateTime() + { + (DateTime? expected, string json)[] testCases = + [ + ( + new DateTime(2023, 10, 5, 14, 30, 0, DateTimeKind.Utc), + "\"2023-10-05T14:30:00.000Z\"" + ), + (null, "null"), + ]; + + foreach (var (expected, json) in testCases) + { + var dateTime = JsonUtils.Deserialize(json); + Assert.That(dateTime, Is.EqualTo(expected)); + } + } +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Core/Json/JsonAccessAttributeTests.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Core/Json/JsonAccessAttributeTests.cs new file mode 100644 index 000000000000..a68a4c8e0662 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Core/Json/JsonAccessAttributeTests.cs @@ -0,0 +1,160 @@ +using global::System.Text.Json.Serialization; +using NUnit.Framework; +using SeedNullableOptional.Core; + +namespace SeedNullableOptional.Test.Core.Json; + +[TestFixture] +public class JsonAccessAttributeTests +{ + private class MyClass + { + [JsonPropertyName("read_only_prop")] + [JsonAccess(JsonAccessType.ReadOnly)] + public string? ReadOnlyProp { get; set; } + + [JsonPropertyName("write_only_prop")] + [JsonAccess(JsonAccessType.WriteOnly)] + public string? WriteOnlyProp { get; set; } + + [JsonPropertyName("normal_prop")] + public string? NormalProp { get; set; } + + [JsonPropertyName("read_only_nullable_list")] + [JsonAccess(JsonAccessType.ReadOnly)] + public IEnumerable? ReadOnlyNullableList { get; set; } + + [JsonPropertyName("read_only_list")] + [JsonAccess(JsonAccessType.ReadOnly)] + public IEnumerable ReadOnlyList { get; set; } = []; + + [JsonPropertyName("write_only_nullable_list")] + [JsonAccess(JsonAccessType.WriteOnly)] + public IEnumerable? WriteOnlyNullableList { get; set; } + + [JsonPropertyName("write_only_list")] + [JsonAccess(JsonAccessType.WriteOnly)] + public IEnumerable WriteOnlyList { get; set; } = []; + + [JsonPropertyName("normal_list")] + public IEnumerable NormalList { get; set; } = []; + + [JsonPropertyName("normal_nullable_list")] + public IEnumerable? NullableNormalList { get; set; } + } + + [Test] + public void JsonAccessAttribute_ShouldWorkAsExpected() + { + const string json = """ + { + "read_only_prop": "read", + "write_only_prop": "write", + "normal_prop": "normal_prop", + "read_only_nullable_list": ["item1", "item2"], + "read_only_list": ["item3", "item4"], + "write_only_nullable_list": ["item5", "item6"], + "write_only_list": ["item7", "item8"], + "normal_list": ["normal1", "normal2"], + "normal_nullable_list": ["normal1", "normal2"] + } + """; + var obj = JsonUtils.Deserialize(json); + + Assert.Multiple(() => + { + // String properties + Assert.That(obj.ReadOnlyProp, Is.EqualTo("read")); + Assert.That(obj.WriteOnlyProp, Is.Null); + Assert.That(obj.NormalProp, Is.EqualTo("normal_prop")); + + // List properties - read only + var nullableReadOnlyList = obj.ReadOnlyNullableList?.ToArray(); + Assert.That(nullableReadOnlyList, Is.Not.Null); + Assert.That(nullableReadOnlyList, Has.Length.EqualTo(2)); + Assert.That(nullableReadOnlyList![0], Is.EqualTo("item1")); + Assert.That(nullableReadOnlyList![1], Is.EqualTo("item2")); + + var readOnlyList = obj.ReadOnlyList.ToArray(); + Assert.That(readOnlyList, Is.Not.Null); + Assert.That(readOnlyList, Has.Length.EqualTo(2)); + Assert.That(readOnlyList[0], Is.EqualTo("item3")); + Assert.That(readOnlyList[1], Is.EqualTo("item4")); + + // List properties - write only + Assert.That(obj.WriteOnlyNullableList, Is.Null); + Assert.That(obj.WriteOnlyList, Is.Not.Null); + Assert.That(obj.WriteOnlyList, Is.Empty); + + // Normal list property + var normalList = obj.NormalList.ToArray(); + Assert.That(normalList, Is.Not.Null); + Assert.That(normalList, Has.Length.EqualTo(2)); + Assert.That(normalList[0], Is.EqualTo("normal1")); + Assert.That(normalList[1], Is.EqualTo("normal2")); + }); + + // Set up values for serialization + obj.WriteOnlyProp = "write"; + obj.NormalProp = "new_value"; + obj.WriteOnlyNullableList = new List { "write1", "write2" }; + obj.WriteOnlyList = new List { "write3", "write4" }; + obj.NormalList = new List { "new_normal" }; + obj.NullableNormalList = new List { "new_normal" }; + + var serializedJson = JsonUtils.Serialize(obj); + const string expectedJson = """ + { + "write_only_prop": "write", + "normal_prop": "new_value", + "write_only_nullable_list": [ + "write1", + "write2" + ], + "write_only_list": [ + "write3", + "write4" + ], + "normal_list": [ + "new_normal" + ], + "normal_nullable_list": [ + "new_normal" + ] + } + """; + Assert.That(serializedJson, Is.EqualTo(expectedJson).IgnoreWhiteSpace); + } + + [Test] + public void JsonAccessAttribute_WithNullListsInJson_ShouldWorkAsExpected() + { + const string json = """ + { + "read_only_prop": "read", + "normal_prop": "normal_prop", + "read_only_nullable_list": null, + "read_only_list": [] + } + """; + var obj = JsonUtils.Deserialize(json); + + Assert.Multiple(() => + { + // Read-only nullable list should be null when JSON contains null + var nullableReadOnlyList = obj.ReadOnlyNullableList?.ToArray(); + Assert.That(nullableReadOnlyList, Is.Null); + + // Read-only non-nullable list should never be null, but empty when JSON contains null + var readOnlyList = obj.ReadOnlyList.ToArray(); // This should be initialized to an empty list by default + Assert.That(readOnlyList, Is.Not.Null); + Assert.That(readOnlyList, Is.Empty); + }); + + // Serialize and verify read-only lists are not included + var serializedJson = JsonUtils.Serialize(obj); + Assert.That(serializedJson, Does.Not.Contain("read_only_prop")); + Assert.That(serializedJson, Does.Not.Contain("read_only_nullable_list")); + Assert.That(serializedJson, Does.Not.Contain("read_only_list")); + } +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Core/Json/OneOfSerializerTests.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Core/Json/OneOfSerializerTests.cs new file mode 100644 index 000000000000..56257ddbcd93 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Core/Json/OneOfSerializerTests.cs @@ -0,0 +1,314 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using NUnit.Framework; +using OneOf; +using SeedNullableOptional.Core; + +namespace SeedNullableOptional.Test.Core.Json; + +[TestFixture] +[Parallelizable(ParallelScope.All)] +public class OneOfSerializerTests +{ + private class Foo + { + [JsonPropertyName("string_prop")] + public required string StringProp { get; set; } + } + + private class Bar + { + [JsonPropertyName("int_prop")] + public required int IntProp { get; set; } + } + + private static readonly OneOf OneOf1 = OneOf< + string, + int, + object, + Foo, + Bar + >.FromT2(new { }); + private const string OneOf1String = "{}"; + + private static readonly OneOf OneOf2 = OneOf< + string, + int, + object, + Foo, + Bar + >.FromT0("test"); + private const string OneOf2String = "\"test\""; + + private static readonly OneOf OneOf3 = OneOf< + string, + int, + object, + Foo, + Bar + >.FromT1(123); + private const string OneOf3String = "123"; + + private static readonly OneOf OneOf4 = OneOf< + string, + int, + object, + Foo, + Bar + >.FromT3(new Foo { StringProp = "test" }); + private const string OneOf4String = "{\"string_prop\": \"test\"}"; + + private static readonly OneOf OneOf5 = OneOf< + string, + int, + object, + Foo, + Bar + >.FromT4(new Bar { IntProp = 5 }); + private const string OneOf5String = "{\"int_prop\": 5}"; + + [Test] + public void Serialize_OneOfs_Should_Return_Expected_String() + { + (OneOf, string)[] testData = + [ + (OneOf1, OneOf1String), + (OneOf2, OneOf2String), + (OneOf3, OneOf3String), + (OneOf4, OneOf4String), + (OneOf5, OneOf5String), + ]; + Assert.Multiple(() => + { + foreach (var (oneOf, expected) in testData) + { + var result = JsonUtils.Serialize(oneOf); + Assert.That(result, Is.EqualTo(expected).IgnoreWhiteSpace); + } + }); + } + + [Test] + public void OneOfs_Should_Deserialize_From_String() + { + (OneOf, string)[] testData = + [ + (OneOf1, OneOf1String), + (OneOf2, OneOf2String), + (OneOf3, OneOf3String), + (OneOf4, OneOf4String), + (OneOf5, OneOf5String), + ]; + Assert.Multiple(() => + { + foreach (var (oneOf, json) in testData) + { + var result = JsonUtils.Deserialize>(json); + Assert.That(result.Index, Is.EqualTo(oneOf.Index)); + Assert.That(json, Is.EqualTo(JsonUtils.Serialize(result.Value)).IgnoreWhiteSpace); + } + }); + } + + private static readonly OneOf? NullableOneOf1 = null; + private const string NullableOneOf1String = "null"; + + private static readonly OneOf? NullableOneOf2 = OneOf< + string, + int, + object, + Foo, + Bar + >.FromT4(new Bar { IntProp = 5 }); + private const string NullableOneOf2String = "{\"int_prop\": 5}"; + + [Test] + public void Serialize_NullableOneOfs_Should_Return_Expected_String() + { + (OneOf?, string)[] testData = + [ + (NullableOneOf1, NullableOneOf1String), + (NullableOneOf2, NullableOneOf2String), + ]; + Assert.Multiple(() => + { + foreach (var (oneOf, expected) in testData) + { + var result = JsonUtils.Serialize(oneOf); + Assert.That(result, Is.EqualTo(expected).IgnoreWhiteSpace); + } + }); + } + + [Test] + public void NullableOneOfs_Should_Deserialize_From_String() + { + (OneOf?, string)[] testData = + [ + (NullableOneOf1, NullableOneOf1String), + (NullableOneOf2, NullableOneOf2String), + ]; + Assert.Multiple(() => + { + foreach (var (oneOf, json) in testData) + { + var result = JsonUtils.Deserialize?>(json); + Assert.That(result?.Index, Is.EqualTo(oneOf?.Index)); + Assert.That(json, Is.EqualTo(JsonUtils.Serialize(result?.Value)).IgnoreWhiteSpace); + } + }); + } + + private static readonly OneOf OneOfWithNullable1 = OneOf< + string, + int, + Foo? + >.FromT2(null); + private const string OneOfWithNullable1String = "null"; + + private static readonly OneOf OneOfWithNullable2 = OneOf< + string, + int, + Foo? + >.FromT2(new Foo { StringProp = "test" }); + private const string OneOfWithNullable2String = "{\"string_prop\": \"test\"}"; + + private static readonly OneOf OneOfWithNullable3 = OneOf< + string, + int, + Foo? + >.FromT0("test"); + private const string OneOfWithNullable3String = "\"test\""; + + [Test] + public void Serialize_OneOfWithNullables_Should_Return_Expected_String() + { + (OneOf, string)[] testData = + [ + (OneOfWithNullable1, OneOfWithNullable1String), + (OneOfWithNullable2, OneOfWithNullable2String), + (OneOfWithNullable3, OneOfWithNullable3String), + ]; + Assert.Multiple(() => + { + foreach (var (oneOf, expected) in testData) + { + var result = JsonUtils.Serialize(oneOf); + Assert.That(result, Is.EqualTo(expected).IgnoreWhiteSpace); + } + }); + } + + [Test] + public void OneOfWithNullables_Should_Deserialize_From_String() + { + (OneOf, string)[] testData = + [ + // (OneOfWithNullable1, OneOfWithNullable1String), // not possible with .NET's JSON serializer + (OneOfWithNullable2, OneOfWithNullable2String), + (OneOfWithNullable3, OneOfWithNullable3String), + ]; + Assert.Multiple(() => + { + foreach (var (oneOf, json) in testData) + { + var result = JsonUtils.Deserialize>(json); + Assert.That(result.Index, Is.EqualTo(oneOf.Index)); + Assert.That(json, Is.EqualTo(JsonUtils.Serialize(result.Value)).IgnoreWhiteSpace); + } + }); + } + + [Test] + public void Serialize_OneOfWithObjectLast_Should_Return_Expected_String() + { + var oneOfWithObjectLast = OneOf.FromT4( + new { random = "data" } + ); + const string oneOfWithObjectLastString = "{\"random\": \"data\"}"; + + var result = JsonUtils.Serialize(oneOfWithObjectLast); + Assert.That(result, Is.EqualTo(oneOfWithObjectLastString).IgnoreWhiteSpace); + } + + [Test] + public void OneOfWithObjectLast_Should_Deserialize_From_String() + { + const string oneOfWithObjectLastString = "{\"random\": \"data\"}"; + var result = JsonUtils.Deserialize>( + oneOfWithObjectLastString + ); + Assert.Multiple(() => + { + Assert.That(result.Index, Is.EqualTo(4)); + Assert.That(result.Value, Is.InstanceOf()); + Assert.That( + JsonUtils.Serialize(result.Value), + Is.EqualTo(oneOfWithObjectLastString).IgnoreWhiteSpace + ); + }); + } + + [Test] + public void Serialize_OneOfWithObjectNotLast_Should_Return_Expected_String() + { + var oneOfWithObjectNotLast = OneOf.FromT1( + new { random = "data" } + ); + const string oneOfWithObjectNotLastString = "{\"random\": \"data\"}"; + + var result = JsonUtils.Serialize(oneOfWithObjectNotLast); + Assert.That(result, Is.EqualTo(oneOfWithObjectNotLastString).IgnoreWhiteSpace); + } + + [Test] + public void OneOfWithObjectNotLast_Should_Deserialize_From_String() + { + const string oneOfWithObjectNotLastString = "{\"random\": \"data\"}"; + var result = JsonUtils.Deserialize>( + oneOfWithObjectNotLastString + ); + Assert.Multiple(() => + { + Assert.That(result.Index, Is.EqualTo(1)); + Assert.That(result.Value, Is.InstanceOf()); + Assert.That( + JsonUtils.Serialize(result.Value), + Is.EqualTo(oneOfWithObjectNotLastString).IgnoreWhiteSpace + ); + }); + } + + [Test] + public void Serialize_OneOfSingleType_Should_Return_Expected_String() + { + var oneOfSingle = OneOf.FromT0("single"); + const string oneOfSingleString = "\"single\""; + + var result = JsonUtils.Serialize(oneOfSingle); + Assert.That(result, Is.EqualTo(oneOfSingleString)); + } + + [Test] + public void OneOfSingleType_Should_Deserialize_From_String() + { + const string oneOfSingleString = "\"single\""; + var result = JsonUtils.Deserialize>(oneOfSingleString); + Assert.Multiple(() => + { + Assert.That(result.Index, Is.EqualTo(0)); + Assert.That(result.Value, Is.EqualTo("single")); + }); + } + + [Test] + public void Deserialize_InvalidData_Should_Throw_Exception() + { + const string invalidJson = "{\"invalid\": \"data\"}"; + + Assert.Throws(() => + { + JsonUtils.Deserialize>(invalidJson); + }); + } +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Core/Json/StringEnumSerializerTests.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Core/Json/StringEnumSerializerTests.cs new file mode 100644 index 000000000000..20c7e1097338 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Core/Json/StringEnumSerializerTests.cs @@ -0,0 +1,138 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using NUnit.Framework; +using SeedNullableOptional.Core; + +namespace SeedNullableOptional.Test.Core.Json; + +[TestFixture] +[Parallelizable(ParallelScope.All)] +public class StringEnumSerializerTests +{ + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + + private static readonly DummyEnum KnownEnumValue2 = DummyEnum.KnownValue2; + private static readonly DummyEnum UnknownEnumValue = DummyEnum.FromCustom("unknown_value"); + + private static readonly string JsonWithKnownEnum2 = $$""" + { + "enum_property": "{{KnownEnumValue2}}" + } + """; + + private static readonly string JsonWithUnknownEnum = $$""" + { + "enum_property": "{{UnknownEnumValue}}" + } + """; + + [Test] + public void ShouldParseKnownEnumValue2() + { + var obj = JsonSerializer.Deserialize(JsonWithKnownEnum2, JsonOptions); + Assert.That(obj, Is.Not.Null); + Assert.That(obj.EnumProperty, Is.EqualTo(KnownEnumValue2)); + } + + [Test] + public void ShouldParseUnknownEnum() + { + var obj = JsonSerializer.Deserialize(JsonWithUnknownEnum, JsonOptions); + Assert.That(obj, Is.Not.Null); + Assert.That(obj.EnumProperty, Is.EqualTo(UnknownEnumValue)); + } + + [Test] + public void ShouldSerializeKnownEnumValue2() + { + var json = JsonSerializer.SerializeToElement( + new DummyObject { EnumProperty = KnownEnumValue2 }, + JsonOptions + ); + TestContext.Out.WriteLine("Serialized JSON: \n" + json); + var enumString = json.GetProperty("enum_property").GetString(); + Assert.That(enumString, Is.Not.Null); + Assert.That(enumString, Is.EqualTo(KnownEnumValue2)); + } + + [Test] + public void ShouldSerializeUnknownEnum() + { + var json = JsonSerializer.SerializeToElement( + new DummyObject { EnumProperty = UnknownEnumValue }, + JsonOptions + ); + TestContext.Out.WriteLine("Serialized JSON: \n" + json); + var enumString = json.GetProperty("enum_property").GetString(); + Assert.That(enumString, Is.Not.Null); + Assert.That(enumString, Is.EqualTo(UnknownEnumValue)); + } +} + +public class DummyObject +{ + [JsonPropertyName("enum_property")] + public DummyEnum EnumProperty { get; set; } +} + +[JsonConverter(typeof(StringEnumSerializer))] +public readonly record struct DummyEnum : IStringEnum +{ + public DummyEnum(string value) + { + Value = value; + } + + /// + /// The string value of the enum. + /// + public string Value { get; } + + public static readonly DummyEnum KnownValue1 = FromCustom(Values.KnownValue1); + + public static readonly DummyEnum KnownValue2 = FromCustom(Values.KnownValue2); + + /// + /// Constant strings for enum values + /// + public static class Values + { + public const string KnownValue1 = "known_value1"; + + public const string KnownValue2 = "known_value2"; + } + + /// + /// Create a string enum with the given value. + /// + public static DummyEnum FromCustom(string value) + { + return new DummyEnum(value); + } + + /// + /// Returns the string value of the enum. + /// + public override string ToString() + { + return Value; + } + + public bool Equals(string? other) + { + return Value.Equals(other); + } + + public override int GetHashCode() + { + return Value.GetHashCode(); + } + + public static explicit operator string(DummyEnum value) => value.Value; + + public static explicit operator DummyEnum(string value) => new(value); + + public static bool operator ==(DummyEnum value1, string value2) => value1.Value.Equals(value2); + + public static bool operator !=(DummyEnum value1, string value2) => !value1.Value.Equals(value2); +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Core/QueryStringConverterTests.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Core/QueryStringConverterTests.cs new file mode 100644 index 000000000000..af5e2c14c93a --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Core/QueryStringConverterTests.cs @@ -0,0 +1,124 @@ +using NUnit.Framework; +using SeedNullableOptional.Core; + +namespace SeedNullableOptional.Test.Core; + +[TestFixture] +public class QueryStringConverterTests +{ + [Test] + public void ToQueryStringCollection_Form() + { + var obj = new + { + Name = "John", + Age = 30, + Address = new + { + Street = "123 Main St", + City = "Anytown", + Coordinates = new[] { 39.781721f, -89.650148f }, + }, + Tags = new[] { "Developer", "Blogger" }, + }; + var result = QueryStringConverter.ToForm(obj); + var expected = new List> + { + new("Name", "John"), + new("Age", "30"), + new("Address[Street]", "123 Main St"), + new("Address[City]", "Anytown"), + new("Address[Coordinates]", "39.78172,-89.65015"), + new("Tags", "Developer,Blogger"), + }; + Assert.That(result, Is.EqualTo(expected)); + } + + [Test] + public void ToQueryStringCollection_ExplodedForm() + { + var obj = new + { + Name = "John", + Age = 30, + Address = new + { + Street = "123 Main St", + City = "Anytown", + Coordinates = new[] { 39.781721f, -89.650148f }, + }, + Tags = new[] { "Developer", "Blogger" }, + }; + var result = QueryStringConverter.ToExplodedForm(obj); + var expected = new List> + { + new("Name", "John"), + new("Age", "30"), + new("Address[Street]", "123 Main St"), + new("Address[City]", "Anytown"), + new("Address[Coordinates]", "39.78172"), + new("Address[Coordinates]", "-89.65015"), + new("Tags", "Developer"), + new("Tags", "Blogger"), + }; + Assert.That(result, Is.EqualTo(expected)); + } + + [Test] + public void ToQueryStringCollection_DeepObject() + { + var obj = new + { + Name = "John", + Age = 30, + Address = new + { + Street = "123 Main St", + City = "Anytown", + Coordinates = new[] { 39.781721f, -89.650148f }, + }, + Tags = new[] { "Developer", "Blogger" }, + }; + var result = QueryStringConverter.ToDeepObject(obj); + var expected = new List> + { + new("Name", "John"), + new("Age", "30"), + new("Address[Street]", "123 Main St"), + new("Address[City]", "Anytown"), + new("Address[Coordinates][0]", "39.78172"), + new("Address[Coordinates][1]", "-89.65015"), + new("Tags[0]", "Developer"), + new("Tags[1]", "Blogger"), + }; + Assert.That(result, Is.EqualTo(expected)); + } + + [Test] + public void ToQueryStringCollection_OnString_ThrowsException() + { + var exception = Assert.Throws(() => + QueryStringConverter.ToForm("invalid") + ); + Assert.That( + exception.Message, + Is.EqualTo( + "Only objects can be converted to query string collections. Given type is String." + ) + ); + } + + [Test] + public void ToQueryStringCollection_OnArray_ThrowsException() + { + var exception = Assert.Throws(() => + QueryStringConverter.ToForm(Array.Empty()) + ); + Assert.That( + exception.Message, + Is.EqualTo( + "Only objects can be converted to query string collections. Given type is Array." + ) + ); + } +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Core/RawClientTests/AdditionalHeadersTests.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Core/RawClientTests/AdditionalHeadersTests.cs new file mode 100644 index 000000000000..1d87813f4573 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Core/RawClientTests/AdditionalHeadersTests.cs @@ -0,0 +1,138 @@ +using NUnit.Framework; +using SeedNullableOptional.Core; +using WireMock.Server; +using SystemTask = global::System.Threading.Tasks.Task; +using WireMockRequest = WireMock.RequestBuilders.Request; +using WireMockResponse = WireMock.ResponseBuilders.Response; + +// ReSharper disable NullableWarningSuppressionIsUsed + +namespace SeedNullableOptional.Test.Core.RawClientTests; + +[TestFixture] +[Parallelizable(ParallelScope.Self)] +public class AdditionalHeadersTests +{ + private WireMockServer _server; + private HttpClient _httpClient; + private RawClient _rawClient; + private string _baseUrl; + + [SetUp] + public void SetUp() + { + _server = WireMockServer.Start(); + _baseUrl = _server.Url ?? ""; + _httpClient = new HttpClient { BaseAddress = new Uri(_baseUrl) }; + _rawClient = new RawClient( + new ClientOptions + { + HttpClient = _httpClient, + Headers = new Headers( + new Dictionary + { + ["a"] = "client_headers", + ["b"] = "client_headers", + ["c"] = "client_headers", + ["d"] = "client_headers", + ["e"] = "client_headers", + ["f"] = "client_headers", + ["client_multiple"] = "client_headers", + } + ), + AdditionalHeaders = new List> + { + new("b", "client_additional_headers"), + new("c", "client_additional_headers"), + new("d", "client_additional_headers"), + new("e", null), + new("client_multiple", "client_additional_headers1"), + new("client_multiple", "client_additional_headers2"), + }, + } + ); + } + + [Test] + public async SystemTask SendRequestAsync_AdditionalHeaderParameters() + { + _server + .Given(WireMockRequest.Create().WithPath("/test").UsingGet()) + .RespondWith(WireMockResponse.Create().WithStatusCode(200).WithBody("Success")); + + var request = new JsonRequest + { + BaseUrl = _baseUrl, + Method = HttpMethod.Get, + Path = "/test", + Headers = new Headers( + new Dictionary + { + ["c"] = "request_headers", + ["d"] = "request_headers", + ["request_multiple"] = "request_headers", + } + ), + Options = new RequestOptions + { + AdditionalHeaders = new List> + { + new("d", "request_additional_headers"), + new("f", null), + new("request_multiple", "request_additional_headers1"), + new("request_multiple", "request_additional_headers2"), + }, + }, + }; + + var response = await _rawClient.SendRequestAsync(request); + Assert.That(response.StatusCode, Is.EqualTo(200)); + + var content = await response.Raw.Content.ReadAsStringAsync(); + Assert.Multiple(() => + { + Assert.That(content, Is.EqualTo("Success")); + Assert.That(_server.LogEntries.Count, Is.EqualTo(1)); + var headers = + _server.LogEntries[0].RequestMessage.Headers ?? throw new global::System.Exception( + "Headers are null" + ); + + Assert.That(headers, Contains.Key("client_multiple")); + Assert.That(headers!["client_multiple"][0], Does.Contain("client_additional_headers1")); + Assert.That(headers["client_multiple"][0], Does.Contain("client_additional_headers2")); + + Assert.That(headers, Contains.Key("request_multiple")); + Assert.That( + headers["request_multiple"][0], + Does.Contain("request_additional_headers1") + ); + Assert.That( + headers["request_multiple"][0], + Does.Contain("request_additional_headers2") + ); + + Assert.That(headers, Contains.Key("a")); + Assert.That(headers["a"][0], Does.Contain("client_headers")); + + Assert.That(headers, Contains.Key("b")); + Assert.That(headers["b"][0], Does.Contain("client_additional_headers")); + + Assert.That(headers, Contains.Key("c")); + Assert.That(headers["c"][0], Does.Contain("request_headers")); + + Assert.That(headers, Contains.Key("d")); + Assert.That(headers["d"][0], Does.Contain("request_additional_headers")); + + Assert.That(headers, Does.Not.ContainKey("e")); + Assert.That(headers, Does.Not.ContainKey("f")); + }); + } + + [TearDown] + public void TearDown() + { + _server.Dispose(); + _httpClient.Dispose(); + } +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Core/RawClientTests/AdditionalParametersTests.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Core/RawClientTests/AdditionalParametersTests.cs new file mode 100644 index 000000000000..2bb26d982dd3 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Core/RawClientTests/AdditionalParametersTests.cs @@ -0,0 +1,300 @@ +using NUnit.Framework; +using SeedNullableOptional.Core; +using WireMock.Matchers; +using WireMock.Server; +using SystemTask = global::System.Threading.Tasks.Task; +using WireMockRequest = WireMock.RequestBuilders.Request; +using WireMockResponse = WireMock.ResponseBuilders.Response; + +namespace SeedNullableOptional.Test.Core.RawClientTests; + +[TestFixture] +[Parallelizable(ParallelScope.Self)] +public class AdditionalParametersTests +{ + private WireMockServer _server; + private HttpClient _httpClient; + private RawClient _rawClient; + private string _baseUrl; + + [SetUp] + public void SetUp() + { + _server = WireMockServer.Start(); + _baseUrl = _server.Url ?? ""; + _httpClient = new HttpClient { BaseAddress = new Uri(_baseUrl) }; + _rawClient = new RawClient(new ClientOptions { HttpClient = _httpClient }); + } + + [Test] + public async SystemTask SendRequestAsync_AdditionalQueryParameters() + { + _server + .Given(WireMockRequest.Create().WithPath("/test").WithParam("foo", "bar").UsingGet()) + .RespondWith(WireMockResponse.Create().WithStatusCode(200).WithBody("Success")); + + var request = new JsonRequest() + { + BaseUrl = _baseUrl, + Method = HttpMethod.Get, + Path = "/test", + Options = new RequestOptions + { + AdditionalQueryParameters = new List> + { + new("foo", "bar"), + }, + }, + }; + + var response = await _rawClient.SendRequestAsync(request); + Assert.That(response.StatusCode, Is.EqualTo(200)); + + var content = await response.Raw.Content.ReadAsStringAsync(); + Assert.That(content, Is.EqualTo("Success")); + + Assert.That(_server.LogEntries.Count, Is.EqualTo(1)); + } + + [Test] + public async SystemTask SendRequestAsync_AdditionalQueryParameters_Override() + { + _server + .Given(WireMockRequest.Create().WithPath("/test").WithParam("foo", "null").UsingGet()) + .RespondWith(WireMockResponse.Create().WithStatusCode(200).WithBody("Success")); + + var request = new JsonRequest() + { + BaseUrl = _baseUrl, + Method = HttpMethod.Get, + Path = "/test", + Query = new Dictionary { { "foo", "bar" } }, + Options = new RequestOptions + { + AdditionalQueryParameters = new List> + { + new("foo", "null"), + }, + }, + }; + + var response = await _rawClient.SendRequestAsync(request); + Assert.That(response.StatusCode, Is.EqualTo(200)); + + var content = await response.Raw.Content.ReadAsStringAsync(); + Assert.That(content, Is.EqualTo("Success")); + + Assert.That(_server.LogEntries.Count, Is.EqualTo(1)); + } + + [Test] + public async SystemTask SendRequestAsync_AdditionalQueryParameters_Merge() + { + _server + .Given(WireMockRequest.Create().WithPath("/test").UsingGet()) + .RespondWith(WireMockResponse.Create().WithStatusCode(200).WithBody("Success")); + + var request = new JsonRequest() + { + BaseUrl = _baseUrl, + Method = HttpMethod.Get, + Path = "/test", + Query = new Dictionary { { "foo", "baz" } }, + Options = new RequestOptions + { + AdditionalQueryParameters = new List> + { + new("foo", "one"), + new("foo", "two"), + }, + }, + }; + + var response = await _rawClient.SendRequestAsync(request); + Assert.That(response.StatusCode, Is.EqualTo(200)); + + var content = await response.Raw.Content.ReadAsStringAsync(); + Assert.That(content, Is.EqualTo("Success")); + + Assert.That(_server.LogEntries.Count, Is.EqualTo(1)); + + var requestUrl = _server.LogEntries.First().RequestMessage.Url; + Assert.That(requestUrl, Does.Contain("foo=one")); + Assert.That(requestUrl, Does.Contain("foo=two")); + Assert.That(requestUrl, Does.Not.Contain("foo=baz")); + } + + [Test] + public async SystemTask SendRequestAsync_AdditionalBodyProperties() + { + string expectedBody = "{\n \"foo\": \"bar\",\n \"baz\": \"qux\"\n}"; + _server + .Given( + WireMockRequest + .Create() + .WithPath("/test") + .UsingPost() + .WithBody(new JsonMatcher(expectedBody)) + ) + .RespondWith(WireMockResponse.Create().WithStatusCode(200).WithBody("Success")); + + var request = new JsonRequest + { + BaseUrl = _baseUrl, + Method = HttpMethod.Post, + Path = "/test", + Body = new Dictionary { { "foo", "bar" } }, + Options = new RequestOptions + { + AdditionalBodyProperties = new Dictionary { { "baz", "qux" } }, + }, + }; + + var response = await _rawClient.SendRequestAsync(request); + Assert.That(response.StatusCode, Is.EqualTo(200)); + + var content = await response.Raw.Content.ReadAsStringAsync(); + Assert.That(content, Is.EqualTo("Success")); + + Assert.That(_server.LogEntries.Count, Is.EqualTo(1)); + } + + [Test] + public async SystemTask SendRequestAsync_AdditionalBodyProperties_Override() + { + string expectedBody = "{\n \"foo\": null\n}"; + _server + .Given( + WireMockRequest + .Create() + .WithPath("/test") + .UsingPost() + .WithBody(new JsonMatcher(expectedBody)) + ) + .RespondWith(WireMockResponse.Create().WithStatusCode(200).WithBody("Success")); + + var request = new JsonRequest + { + BaseUrl = _baseUrl, + Method = HttpMethod.Post, + Path = "/test", + Body = new Dictionary { { "foo", "bar" } }, + Options = new RequestOptions + { + AdditionalBodyProperties = new Dictionary { { "foo", null } }, + }, + }; + + var response = await _rawClient.SendRequestAsync(request); + Assert.That(response.StatusCode, Is.EqualTo(200)); + + var content = await response.Raw.Content.ReadAsStringAsync(); + Assert.That(content, Is.EqualTo("Success")); + + Assert.That(_server.LogEntries.Count, Is.EqualTo(1)); + } + + [Test] + public async SystemTask SendRequestAsync_AdditionalBodyProperties_DeepMerge() + { + const string expectedBody = """ + { + "foo": { + "inner1": "original", + "inner2": "overridden", + "inner3": { + "deepProp1": "deep-override", + "deepProp2": "original", + "deepProp3": null, + "deepProp4": "new-value" + } + }, + "bar": "new-value", + "baz": ["new","value"] + } + """; + + _server + .Given( + WireMockRequest + .Create() + .WithPath("/test-deep-merge") + .UsingPost() + .WithBody(new JsonMatcher(expectedBody)) + ) + .RespondWith(WireMockResponse.Create().WithStatusCode(200).WithBody("Success")); + + var request = new JsonRequest + { + BaseUrl = _baseUrl, + Method = HttpMethod.Post, + Path = "/test-deep-merge", + Body = new Dictionary + { + { + "foo", + new Dictionary + { + { "inner1", "original" }, + { "inner2", "original" }, + { + "inner3", + new Dictionary + { + { "deepProp1", "deep-original" }, + { "deepProp2", "original" }, + { "deepProp3", "" }, + } + }, + } + }, + { + "baz", + new List { "original" } + }, + }, + Options = new RequestOptions + { + AdditionalBodyProperties = new Dictionary + { + { + "foo", + new Dictionary + { + { "inner2", "overridden" }, + { + "inner3", + new Dictionary + { + { "deepProp1", "deep-override" }, + { "deepProp3", null }, + { "deepProp4", "new-value" }, + } + }, + } + }, + { "bar", "new-value" }, + { + "baz", + new List { "new", "value" } + }, + }, + }, + }; + + var response = await _rawClient.SendRequestAsync(request); + Assert.That(response.StatusCode, Is.EqualTo(200)); + + var content = await response.Raw.Content.ReadAsStringAsync(); + Assert.That(content, Is.EqualTo("Success")); + + Assert.That(_server.LogEntries.Count, Is.EqualTo(1)); + } + + [TearDown] + public void TearDown() + { + _server.Dispose(); + _httpClient.Dispose(); + } +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Core/RawClientTests/MultipartFormTests.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Core/RawClientTests/MultipartFormTests.cs new file mode 100644 index 000000000000..894d04d2f338 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Core/RawClientTests/MultipartFormTests.cs @@ -0,0 +1,1120 @@ +using global::System.Net.Http; +using global::System.Text; +using global::System.Text.Json.Serialization; +using NUnit.Framework; +using SeedNullableOptional.Core; +using SystemTask = global::System.Threading.Tasks.Task; + +namespace SeedNullableOptional.Test.Core.RawClientTests; + +[TestFixture] +[Parallelizable(ParallelScope.Self)] +public class MultipartFormTests +{ + private static SimpleObject _simpleObject = new(); + + private static string _simpleFormEncoded = + "meta=data&Date=2023-10-01&Time=12:00:00&Duration=01:00:00&Id=1a1bb98f-47c6-407b-9481-78476affe52a&IsActive=true&Count=42&Initial=A&Values=data,2023-10-01,12:00:00,01:00:00,1a1bb98f-47c6-407b-9481-78476affe52a,true,42,A"; + + private static string _simpleExplodedFormEncoded = + "meta=data&Date=2023-10-01&Time=12:00:00&Duration=01:00:00&Id=1a1bb98f-47c6-407b-9481-78476affe52a&IsActive=true&Count=42&Initial=A&Values=data&Values=2023-10-01&Values=12:00:00&Values=01:00:00&Values=1a1bb98f-47c6-407b-9481-78476affe52a&Values=true&Values=42&Values=A"; + + private static ComplexObject _complexObject = new(); + + private static string _complexJson = """ + { + "meta": "data", + "Nested": { + "foo": "value" + }, + "NestedDictionary": { + "key": { + "foo": "value" + } + }, + "ListOfObjects": [ + { + "foo": "value" + }, + { + "foo": "value2" + } + ], + "Date": "2023-10-01", + "Time": "12:00:00", + "Duration": "01:00:00", + "Id": "1a1bb98f-47c6-407b-9481-78476affe52a", + "IsActive": true, + "Count": 42, + "Initial": "A" + } + """; + + [Test] + public async SystemTask ShouldAddStringPart() + { + const string partInput = "string content"; + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddStringPart("string", partInput); + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: text/plain + Content-Disposition: form-data; name=string + + {partInput} + --{boundary}-- + """; + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddStringParts() + { + const string partInput = "string content"; + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddStringParts("strings", [partInput, partInput]); + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: text/plain + Content-Disposition: form-data; name=strings + + {partInput} + --{boundary} + Content-Type: text/plain + Content-Disposition: form-data; name=strings + + {partInput} + --{boundary}-- + """; + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask GivenNull_ShouldNotAddStringPart() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddStringPart("string", null); + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + --{boundary}-- + """; + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddStringParts_WithNullsInList() + { + const string partInput = "string content"; + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddStringParts("strings", [partInput, null, partInput]); + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: text/plain + Content-Disposition: form-data; name=strings + + {partInput} + --{boundary} + Content-Type: text/plain + Content-Disposition: form-data; name=strings + + {partInput} + --{boundary}-- + """; + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddStringPart_WithContentType() + { + const string partInput = "string content"; + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddStringPart("string", partInput, "text/xml"); + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: text/xml + Content-Disposition: form-data; name=string + + {partInput} + --{boundary}-- + """; + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddStringPart_WithContentTypeAndCharset() + { + const string partInput = "string content"; + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddStringPart("string", partInput, "text/xml; charset=utf-8"); + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: text/xml; charset=utf-8 + Content-Disposition: form-data; name=string + + {partInput} + --{boundary}-- + """; + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddStringParts_WithContentType() + { + const string partInput = "string content"; + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddStringParts("strings", [partInput, partInput], "text/xml"); + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: text/xml + Content-Disposition: form-data; name=strings + + {partInput} + --{boundary} + Content-Type: text/xml + Content-Disposition: form-data; name=strings + + {partInput} + --{boundary}-- + """; + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddStringParts_WithContentTypeAndCharset() + { + const string partInput = "string content"; + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddStringParts( + "strings", + [partInput, partInput], + "text/xml; charset=utf-8" + ); + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: text/xml; charset=utf-8 + Content-Disposition: form-data; name=strings + + {partInput} + --{boundary} + Content-Type: text/xml; charset=utf-8 + Content-Disposition: form-data; name=strings + + {partInput} + --{boundary}-- + """; + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddFileParameter_WithFileName() + { + var (partInput, partExpectedString) = GetFileParameterTestData(); + var file = new FileParameter { Stream = partInput, FileName = "test.txt" }; + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddFileParameterPart("file", file); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/octet-stream + Content-Disposition: form-data; name=file; filename=test.txt; filename*=utf-8''test.txt + + {partExpectedString} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddFileParameter_WithoutFileName() + { + var (partInput, partExpectedString) = GetFileParameterTestData(); + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddFileParameterPart("file", partInput); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/octet-stream + Content-Disposition: form-data; name=file + + {partExpectedString} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddFileParameter_WithContentType() + { + var (partInput, partExpectedString) = GetFileParameterTestData(); + var file = new FileParameter + { + Stream = partInput, + FileName = "test.txt", + ContentType = "text/plain", + }; + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddFileParameterPart("file", file, "ignored-fallback-content-type"); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: text/plain + Content-Disposition: form-data; name=file; filename=test.txt; filename*=utf-8''test.txt + + {partExpectedString} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddFileParameter_WithContentTypeAndCharset() + { + var (partInput, partExpectedString) = GetFileParameterTestData(); + var file = new FileParameter + { + Stream = partInput, + FileName = "test.txt", + ContentType = "text/plain; charset=utf-8", + }; + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddFileParameterPart("file", file, "ignored-fallback-content-type"); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: text/plain; charset=utf-8 + Content-Disposition: form-data; name=file; filename=test.txt; filename*=utf-8''test.txt + + {partExpectedString} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddFileParameter_WithFallbackContentType() + { + var (partInput, partExpectedString) = GetFileParameterTestData(); + var file = new FileParameter { Stream = partInput, FileName = "test.txt" }; + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddFileParameterPart("file", file, "text/plain"); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: text/plain + Content-Disposition: form-data; name=file; filename=test.txt; filename*=utf-8''test.txt + + {partExpectedString} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddFileParameter_WithFallbackContentTypeAndCharset() + { + var (partInput, partExpectedString) = GetFileParameterTestData(); + var file = new FileParameter { Stream = partInput, FileName = "test.txt" }; + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddFileParameterPart("file", file, "text/plain; charset=utf-8"); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: text/plain; charset=utf-8 + Content-Disposition: form-data; name=file; filename=test.txt; filename*=utf-8''test.txt + + {partExpectedString} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddFileParameters() + { + var (partInput1, partExpectedString1) = GetFileParameterTestData(); + var (partInput2, partExpectedString2) = GetFileParameterTestData(); + var file1 = new FileParameter { Stream = partInput1, FileName = "test1.txt" }; + var file2 = new FileParameter { Stream = partInput2, FileName = "test2.txt" }; + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddFileParameterParts("file", [file1, file2]); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/octet-stream + Content-Disposition: form-data; name=file; filename=test1.txt; filename*=utf-8''test1.txt + + {partExpectedString1} + --{boundary} + Content-Type: application/octet-stream + Content-Disposition: form-data; name=file; filename=test2.txt; filename*=utf-8''test2.txt + + {partExpectedString2} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddFileParameters_WithNullsInList() + { + var (partInput1, partExpectedString1) = GetFileParameterTestData(); + var (partInput2, partExpectedString2) = GetFileParameterTestData(); + var file1 = new FileParameter { Stream = partInput1, FileName = "test1.txt" }; + var file2 = new FileParameter { Stream = partInput2, FileName = "test2.txt" }; + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddFileParameterParts("file", [file1, null, file2]); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/octet-stream + Content-Disposition: form-data; name=file; filename=test1.txt; filename*=utf-8''test1.txt + + {partExpectedString1} + --{boundary} + Content-Type: application/octet-stream + Content-Disposition: form-data; name=file; filename=test2.txt; filename*=utf-8''test2.txt + + {partExpectedString2} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask GivenNull_ShouldNotAddFileParameter() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddFileParameterPart("file", null); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddJsonPart_WithComplexObject() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddJsonPart("object", _complexObject); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/json + Content-Disposition: form-data; name=object + + {_complexJson} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddJsonPart_WithComplexObjectList() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddJsonParts("objects", [_complexObject, _complexObject]); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/json + Content-Disposition: form-data; name=objects + + {_complexJson} + --{boundary} + Content-Type: application/json + Content-Disposition: form-data; name=objects + + {_complexJson} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask GivenNull_ShouldNotAddJsonPart() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddJsonPart("object", null); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddJsonParts_WithNullsInList() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddJsonParts("objects", [_complexObject, null]); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/json + Content-Disposition: form-data; name=objects + + {_complexJson} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddJsonParts_WithContentType() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddJsonParts("objects", [new { }], "application/json-patch+json"); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $$""" + --{{boundary}} + Content-Type: application/json-patch+json + Content-Disposition: form-data; name=objects + + {} + --{{boundary}}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddFormEncodedParts_WithSimpleObject() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddFormEncodedPart("object", _simpleObject); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/x-www-form-urlencoded + Content-Disposition: form-data; name=object + + {EscapeFormEncodedString(_simpleFormEncoded)} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddFormEncodedParts_WithSimpleObjectList() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddFormEncodedParts("objects", [_simpleObject, _simpleObject]); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/x-www-form-urlencoded + Content-Disposition: form-data; name=objects + + {EscapeFormEncodedString(_simpleFormEncoded)} + --{boundary} + Content-Type: application/x-www-form-urlencoded + Content-Disposition: form-data; name=objects + + {EscapeFormEncodedString(_simpleFormEncoded)} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldNotAddFormEncodedParts_WithNull() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddFormEncodedParts("object", null); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldNotAddFormEncodedParts_WithNullsInList() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddFormEncodedParts("objects", [_simpleObject, null]); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/x-www-form-urlencoded + Content-Disposition: form-data; name=objects + + {EscapeFormEncodedString(_simpleFormEncoded)} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddFormEncodedPart_WithContentType() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddFormEncodedPart( + "objects", + new { foo = "bar" }, + "application/x-www-form-urlencoded" + ); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/x-www-form-urlencoded + Content-Disposition: form-data; name=objects + + foo=bar + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddFormEncodedPart_WithContentTypeAndCharset() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddFormEncodedPart( + "objects", + new { foo = "bar" }, + "application/x-www-form-urlencoded; charset=utf-8" + ); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/x-www-form-urlencoded; charset=utf-8 + Content-Disposition: form-data; name=objects + + foo=bar + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddFormEncodedParts_WithContentType() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddFormEncodedParts( + "objects", + [new { foo = "bar" }], + "application/x-www-form-urlencoded" + ); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/x-www-form-urlencoded + Content-Disposition: form-data; name=objects + + foo=bar + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddFormEncodedParts_WithContentTypeAndCharset() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddFormEncodedParts( + "objects", + [new { foo = "bar" }], + "application/x-www-form-urlencoded; charset=utf-8" + ); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/x-www-form-urlencoded; charset=utf-8 + Content-Disposition: form-data; name=objects + + foo=bar + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddExplodedFormEncodedParts_WithSimpleObject() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddExplodedFormEncodedPart("object", _simpleObject); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/x-www-form-urlencoded + Content-Disposition: form-data; name=object + + {EscapeFormEncodedString(_simpleExplodedFormEncoded)} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddExplodedFormEncodedParts_WithSimpleObjectList() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddExplodedFormEncodedParts("objects", [_simpleObject, _simpleObject]); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/x-www-form-urlencoded + Content-Disposition: form-data; name=objects + + {EscapeFormEncodedString(_simpleExplodedFormEncoded)} + --{boundary} + Content-Type: application/x-www-form-urlencoded + Content-Disposition: form-data; name=objects + + {EscapeFormEncodedString(_simpleExplodedFormEncoded)} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldNotAddExplodedFormEncodedParts_WithNull() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddExplodedFormEncodedPart("object", null); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldNotAddExplodedFormEncodedParts_WithNullsInList() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddExplodedFormEncodedParts("objects", [_simpleObject, null]); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/x-www-form-urlencoded + Content-Disposition: form-data; name=objects + + {EscapeFormEncodedString(_simpleExplodedFormEncoded)} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddExplodedFormEncodedPart_WithContentType() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddExplodedFormEncodedPart( + "objects", + new { foo = "bar" }, + "application/x-www-form-urlencoded" + ); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/x-www-form-urlencoded + Content-Disposition: form-data; name=objects + + foo=bar + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddExplodedFormEncodedPart_WithContentTypeAndCharset() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddExplodedFormEncodedPart( + "objects", + new { foo = "bar" }, + "application/x-www-form-urlencoded; charset=utf-8" + ); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/x-www-form-urlencoded; charset=utf-8 + Content-Disposition: form-data; name=objects + + foo=bar + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddExplodedFormEncodedParts_WithContentType() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddExplodedFormEncodedParts( + "objects", + [new { foo = "bar" }], + "application/x-www-form-urlencoded" + ); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/x-www-form-urlencoded + Content-Disposition: form-data; name=objects + + foo=bar + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddExplodedFormEncodedParts_WithContentTypeAndCharset() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddExplodedFormEncodedParts( + "objects", + [new { foo = "bar" }], + "application/x-www-form-urlencoded; charset=utf-8" + ); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/x-www-form-urlencoded; charset=utf-8 + Content-Disposition: form-data; name=objects + + foo=bar + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + private static string EscapeFormEncodedString(string input) + { + return string.Join( + "&", + input + .Split('&') + .Select(x => x.Split('=')) + .Select(x => $"{Uri.EscapeDataString(x[0])}={Uri.EscapeDataString(x[1])}") + ); + } + + private static string GetBoundary(MultipartFormDataContent content) + { + return content + .Headers.ContentType?.Parameters.Single(p => + p.Name.Equals("boundary", StringComparison.OrdinalIgnoreCase) + ) + .Value?.Trim('"') ?? throw new global::System.Exception("Boundary not found"); + } + + private static SeedNullableOptional.Core.MultipartFormRequest CreateMultipartFormRequest() + { + return new SeedNullableOptional.Core.MultipartFormRequest + { + BaseUrl = "https://localhost", + Method = HttpMethod.Post, + Path = "", + }; + } + + private static (Stream partInput, string partExpectedString) GetFileParameterTestData() + { + const string partExpectedString = "file content"; + var partInput = new MemoryStream(Encoding.Default.GetBytes(partExpectedString)); + return (partInput, partExpectedString); + } + + private class SimpleObject + { + [JsonPropertyName("meta")] + public string Meta { get; set; } = "data"; + public DateOnly Date { get; set; } = DateOnly.Parse("2023-10-01"); + public TimeOnly Time { get; set; } = TimeOnly.Parse("12:00:00"); + public TimeSpan Duration { get; set; } = TimeSpan.FromHours(1); + public Guid Id { get; set; } = Guid.Parse("1a1bb98f-47c6-407b-9481-78476affe52a"); + public bool IsActive { get; set; } = true; + public int Count { get; set; } = 42; + public char Initial { get; set; } = 'A'; + public IEnumerable Values { get; set; } = + [ + "data", + DateOnly.Parse("2023-10-01"), + TimeOnly.Parse("12:00:00"), + TimeSpan.FromHours(1), + Guid.Parse("1a1bb98f-47c6-407b-9481-78476affe52a"), + true, + 42, + 'A', + ]; + } + + private class ComplexObject + { + [JsonPropertyName("meta")] + public string Meta { get; set; } = "data"; + + public object Nested { get; set; } = new { foo = "value" }; + + public Dictionary NestedDictionary { get; set; } = + new() { { "key", new { foo = "value" } } }; + + public IEnumerable ListOfObjects { get; set; } = + new List { new { foo = "value" }, new { foo = "value2" } }; + + public DateOnly Date { get; set; } = DateOnly.Parse("2023-10-01"); + public TimeOnly Time { get; set; } = TimeOnly.Parse("12:00:00"); + public TimeSpan Duration { get; set; } = TimeSpan.FromHours(1); + public Guid Id { get; set; } = Guid.Parse("1a1bb98f-47c6-407b-9481-78476affe52a"); + public bool IsActive { get; set; } = true; + public int Count { get; set; } = 42; + public char Initial { get; set; } = 'A'; + } +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Core/RawClientTests/QueryParameterTests.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Core/RawClientTests/QueryParameterTests.cs new file mode 100644 index 000000000000..2dc4337bb228 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Core/RawClientTests/QueryParameterTests.cs @@ -0,0 +1,64 @@ +using NUnit.Framework; +using SeedNullableOptional.Core; +using WireMock.Matchers; +using WireMock.Server; +using SystemTask = global::System.Threading.Tasks.Task; +using WireMockRequest = WireMock.RequestBuilders.Request; +using WireMockResponse = WireMock.ResponseBuilders.Response; + +namespace SeedNullableOptional.Test.Core.RawClientTests; + +[TestFixture] +[Parallelizable(ParallelScope.Self)] +public class QueryParameterTests +{ + private WireMockServer _server; + private HttpClient _httpClient; + private RawClient _rawClient; + private string _baseUrl; + + [SetUp] + public void SetUp() + { + _server = WireMockServer.Start(); + _baseUrl = _server.Url ?? ""; + _httpClient = new HttpClient { BaseAddress = new Uri(_baseUrl) }; + _rawClient = new RawClient(new ClientOptions { HttpClient = _httpClient }); + } + + [Test] + public async SystemTask CreateRequest_QueryParametersEscaping() + { + _server + .Given(WireMockRequest.Create().WithPath("/test").WithParam("foo", "bar").UsingGet()) + .RespondWith(WireMockResponse.Create().WithStatusCode(200).WithBody("Success")); + + var request = new JsonRequest() + { + BaseUrl = _baseUrl, + Method = HttpMethod.Get, + Path = "/test", + Query = new Dictionary + { + { "sample", "value" }, + { "email", "bob+test@example.com" }, + { "%Complete", "100" }, + }, + Options = new RequestOptions(), + }; + + var httpRequest = await _rawClient.CreateHttpRequestAsync(request).ConfigureAwait(false); + var url = httpRequest.RequestUri!.AbsoluteUri; + + Assert.That(url, Does.Contain("sample=value")); + Assert.That(url, Does.Contain("email=bob%2Btest%40example.com")); + Assert.That(url, Does.Contain("%25Complete=100")); + } + + [TearDown] + public void TearDown() + { + _server.Dispose(); + _httpClient.Dispose(); + } +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Core/RawClientTests/RetriesTests.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Core/RawClientTests/RetriesTests.cs new file mode 100644 index 000000000000..5e7443ad004e --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Core/RawClientTests/RetriesTests.cs @@ -0,0 +1,327 @@ +using global::System.Net.Http; +using NUnit.Framework; +using SeedNullableOptional.Core; +using WireMock.Server; +using SystemTask = global::System.Threading.Tasks.Task; +using WireMockRequest = WireMock.RequestBuilders.Request; +using WireMockResponse = WireMock.ResponseBuilders.Response; + +namespace SeedNullableOptional.Test.Core.RawClientTests; + +[TestFixture] +[Parallelizable(ParallelScope.Self)] +public class RetriesTests +{ + private const int MaxRetries = 3; + private WireMockServer _server; + private HttpClient _httpClient; + private RawClient _rawClient; + private string _baseUrl; + + [SetUp] + public void SetUp() + { + _server = WireMockServer.Start(); + _baseUrl = _server.Url ?? ""; + _httpClient = new HttpClient { BaseAddress = new Uri(_baseUrl) }; + _rawClient = new RawClient( + new ClientOptions { HttpClient = _httpClient, MaxRetries = MaxRetries } + ) + { + BaseRetryDelay = 0, + }; + } + + [Test] + [TestCase(408)] + [TestCase(429)] + [TestCase(500)] + [TestCase(504)] + public async SystemTask SendRequestAsync_ShouldRetry_OnRetryableStatusCodes(int statusCode) + { + _server + .Given(WireMockRequest.Create().WithPath("/test").UsingGet()) + .InScenario("Retry") + .WillSetStateTo("Server Error") + .RespondWith(WireMockResponse.Create().WithStatusCode(statusCode)); + + _server + .Given(WireMockRequest.Create().WithPath("/test").UsingGet()) + .InScenario("Retry") + .WhenStateIs("Server Error") + .WillSetStateTo("Success") + .RespondWith(WireMockResponse.Create().WithStatusCode(statusCode)); + + _server + .Given(WireMockRequest.Create().WithPath("/test").UsingGet()) + .InScenario("Retry") + .WhenStateIs("Success") + .RespondWith(WireMockResponse.Create().WithStatusCode(200).WithBody("Success")); + + var request = new EmptyRequest + { + BaseUrl = _baseUrl, + Method = HttpMethod.Get, + Path = "/test", + }; + + var response = await _rawClient.SendRequestAsync(request); + Assert.That(response.StatusCode, Is.EqualTo(200)); + + var content = await response.Raw.Content.ReadAsStringAsync(); + using (Assert.EnterMultipleScope()) + { + Assert.That(content, Is.EqualTo("Success")); + + Assert.That(_server.LogEntries, Has.Count.EqualTo(MaxRetries)); + } + } + + [Test] + [TestCase(400)] + [TestCase(409)] + public async SystemTask SendRequestAsync_ShouldRetry_OnNonRetryableStatusCodes(int statusCode) + { + _server + .Given(WireMockRequest.Create().WithPath("/test").UsingGet()) + .InScenario("Retry") + .WillSetStateTo("Server Error") + .RespondWith(WireMockResponse.Create().WithStatusCode(statusCode).WithBody("Failure")); + + var request = new JsonRequest + { + BaseUrl = _baseUrl, + Method = HttpMethod.Get, + Path = "/test", + Body = new { }, + }; + + var response = await _rawClient.SendRequestAsync(request); + Assert.That(response.StatusCode, Is.EqualTo(statusCode)); + + var content = await response.Raw.Content.ReadAsStringAsync(); + Assert.Multiple(() => + { + Assert.That(content, Is.EqualTo("Failure")); + + Assert.That(_server.LogEntries, Has.Count.EqualTo(1)); + }); + } + + [Test] + public async SystemTask SendRequestAsync_ShouldNotRetry_WithStreamRequest() + { + _server + .Given(WireMockRequest.Create().WithPath("/test").UsingPost()) + .InScenario("Retry") + .WillSetStateTo("Server Error") + .RespondWith(WireMockResponse.Create().WithStatusCode(429).WithBody("Failure")); + + var request = new StreamRequest + { + BaseUrl = _baseUrl, + Method = HttpMethod.Post, + Path = "/test", + Body = new MemoryStream(), + }; + + var response = await _rawClient.SendRequestAsync(request); + Assert.That(response.StatusCode, Is.EqualTo(429)); + + var content = await response.Raw.Content.ReadAsStringAsync(); + Assert.Multiple(() => + { + Assert.That(content, Is.EqualTo("Failure")); + Assert.That(_server.LogEntries, Has.Count.EqualTo(1)); + }); + } + + [Test] + public async SystemTask SendRequestAsync_ShouldNotRetry_WithMultiPartFormRequest_WithStream() + { + _server + .Given(WireMockRequest.Create().WithPath("/test").UsingPost()) + .InScenario("Retry") + .WillSetStateTo("Server Error") + .RespondWith(WireMockResponse.Create().WithStatusCode(429).WithBody("Failure")); + + var request = new SeedNullableOptional.Core.MultipartFormRequest + { + BaseUrl = _baseUrl, + Method = HttpMethod.Post, + Path = "/test", + }; + request.AddFileParameterPart("file", new MemoryStream()); + + var response = await _rawClient.SendRequestAsync(request); + Assert.That(response.StatusCode, Is.EqualTo(429)); + + var content = await response.Raw.Content.ReadAsStringAsync(); + Assert.Multiple(() => + { + Assert.That(content, Is.EqualTo("Failure")); + Assert.That(_server.LogEntries, Has.Count.EqualTo(1)); + }); + } + + [Test] + public async SystemTask SendRequestAsync_ShouldRetry_WithMultiPartFormRequest_WithoutStream() + { + _server + .Given(WireMockRequest.Create().WithPath("/test").UsingPost()) + .InScenario("Retry") + .WillSetStateTo("Server Error") + .RespondWith(WireMockResponse.Create().WithStatusCode(429)); + + _server + .Given(WireMockRequest.Create().WithPath("/test").UsingPost()) + .InScenario("Retry") + .WhenStateIs("Server Error") + .WillSetStateTo("Success") + .RespondWith(WireMockResponse.Create().WithStatusCode(429)); + + _server + .Given(WireMockRequest.Create().WithPath("/test").UsingPost()) + .InScenario("Retry") + .WhenStateIs("Success") + .RespondWith(WireMockResponse.Create().WithStatusCode(200).WithBody("Success")); + + var request = new SeedNullableOptional.Core.MultipartFormRequest + { + BaseUrl = _baseUrl, + Method = HttpMethod.Post, + Path = "/test", + }; + request.AddJsonPart("object", new { }); + + var response = await _rawClient.SendRequestAsync(request); + Assert.That(response.StatusCode, Is.EqualTo(200)); + + var content = await response.Raw.Content.ReadAsStringAsync(); + Assert.Multiple(() => + { + Assert.That(content, Is.EqualTo("Success")); + Assert.That(_server.LogEntries, Has.Count.EqualTo(MaxRetries)); + }); + } + + [Test] + public async SystemTask SendRequestAsync_ShouldRespectRetryAfterHeader_WithSecondsValue() + { + _server + .Given(WireMockRequest.Create().WithPath("/test").UsingGet()) + .InScenario("RetryAfter") + .WillSetStateTo("Success") + .RespondWith( + WireMockResponse.Create().WithStatusCode(429).WithHeader("Retry-After", "1") + ); + + _server + .Given(WireMockRequest.Create().WithPath("/test").UsingGet()) + .InScenario("RetryAfter") + .WhenStateIs("Success") + .RespondWith(WireMockResponse.Create().WithStatusCode(200).WithBody("Success")); + + var request = new EmptyRequest + { + BaseUrl = _baseUrl, + Method = HttpMethod.Get, + Path = "/test", + }; + + var response = await _rawClient.SendRequestAsync(request); + Assert.That(response.StatusCode, Is.EqualTo(200)); + + var content = await response.Raw.Content.ReadAsStringAsync(); + Assert.Multiple(() => + { + Assert.That(content, Is.EqualTo("Success")); + Assert.That(_server.LogEntries, Has.Count.EqualTo(2)); + }); + } + + [Test] + public async SystemTask SendRequestAsync_ShouldRespectRetryAfterHeader_WithHttpDateValue() + { + var retryAfterDate = DateTimeOffset.UtcNow.AddSeconds(1).ToString("R"); + _server + .Given(WireMockRequest.Create().WithPath("/test").UsingGet()) + .InScenario("RetryAfterDate") + .WillSetStateTo("Success") + .RespondWith( + WireMockResponse + .Create() + .WithStatusCode(429) + .WithHeader("Retry-After", retryAfterDate) + ); + + _server + .Given(WireMockRequest.Create().WithPath("/test").UsingGet()) + .InScenario("RetryAfterDate") + .WhenStateIs("Success") + .RespondWith(WireMockResponse.Create().WithStatusCode(200).WithBody("Success")); + + var request = new EmptyRequest + { + BaseUrl = _baseUrl, + Method = HttpMethod.Get, + Path = "/test", + }; + + var response = await _rawClient.SendRequestAsync(request); + Assert.That(response.StatusCode, Is.EqualTo(200)); + + var content = await response.Raw.Content.ReadAsStringAsync(); + Assert.Multiple(() => + { + Assert.That(content, Is.EqualTo("Success")); + Assert.That(_server.LogEntries, Has.Count.EqualTo(2)); + }); + } + + [Test] + public async SystemTask SendRequestAsync_ShouldRespectXRateLimitResetHeader() + { + var resetTime = DateTimeOffset.UtcNow.AddSeconds(1).ToUnixTimeSeconds().ToString(); + _server + .Given(WireMockRequest.Create().WithPath("/test").UsingGet()) + .InScenario("RateLimitReset") + .WillSetStateTo("Success") + .RespondWith( + WireMockResponse + .Create() + .WithStatusCode(429) + .WithHeader("X-RateLimit-Reset", resetTime) + ); + + _server + .Given(WireMockRequest.Create().WithPath("/test").UsingGet()) + .InScenario("RateLimitReset") + .WhenStateIs("Success") + .RespondWith(WireMockResponse.Create().WithStatusCode(200).WithBody("Success")); + + var request = new EmptyRequest + { + BaseUrl = _baseUrl, + Method = HttpMethod.Get, + Path = "/test", + }; + + var response = await _rawClient.SendRequestAsync(request); + Assert.That(response.StatusCode, Is.EqualTo(200)); + + var content = await response.Raw.Content.ReadAsStringAsync(); + Assert.Multiple(() => + { + Assert.That(content, Is.EqualTo("Success")); + Assert.That(_server.LogEntries, Has.Count.EqualTo(2)); + }); + } + + [TearDown] + public void TearDown() + { + _server.Dispose(); + _httpClient.Dispose(); + } +} diff --git a/seed/csharp-sdk/nullable/src/SeedNullable.Test/SeedNullable.Test.Custom.props b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/SeedNullableOptional.Test.Custom.props similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable.Test/SeedNullable.Test.Custom.props rename to seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/SeedNullableOptional.Test.Custom.props diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/SeedNullableOptional.Test.csproj b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/SeedNullableOptional.Test.csproj new file mode 100644 index 000000000000..d0f82449d197 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/SeedNullableOptional.Test.csproj @@ -0,0 +1,39 @@ + + + net8.0 + 12 + enable + enable + false + true + true + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/TestClient.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/TestClient.cs new file mode 100644 index 000000000000..9a548c91fc64 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/TestClient.cs @@ -0,0 +1,6 @@ +using NUnit.Framework; + +namespace SeedNullableOptional.Test; + +[TestFixture] +public class TestClient; diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Unit/MockServer/BaseMockServerTest.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Unit/MockServer/BaseMockServerTest.cs new file mode 100644 index 000000000000..9660abcd3f0d --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Unit/MockServer/BaseMockServerTest.cs @@ -0,0 +1,38 @@ +using NUnit.Framework; +using SeedNullableOptional; +using WireMock.Logging; +using WireMock.Server; +using WireMock.Settings; + +namespace SeedNullableOptional.Test.Unit.MockServer; + +[SetUpFixture] +public class BaseMockServerTest +{ + protected static WireMockServer Server { get; set; } = null!; + + protected static SeedNullableOptionalClient Client { get; set; } = null!; + + protected static RequestOptions RequestOptions { get; set; } = new(); + + [OneTimeSetUp] + public void GlobalSetup() + { + // Start the WireMock server + Server = WireMockServer.Start( + new WireMockServerSettings { Logger = new WireMockConsoleLogger() } + ); + + // Initialize the Client + Client = new SeedNullableOptionalClient( + clientOptions: new ClientOptions { BaseUrl = Server.Urls[0], MaxRetries = 0 } + ); + } + + [OneTimeTearDown] + public void GlobalTeardown() + { + Server.Stop(); + Server.Dispose(); + } +} diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/CreateComplexProfileTest.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Unit/MockServer/CreateComplexProfileTest.cs similarity index 99% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/CreateComplexProfileTest.cs rename to seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Unit/MockServer/CreateComplexProfileTest.cs index a9de5568edd1..67bbcee32e56 100644 --- a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Unit/MockServer/CreateComplexProfileTest.cs +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Unit/MockServer/CreateComplexProfileTest.cs @@ -360,7 +360,7 @@ public async Task MockServerTest() "nullableListOfNullables", "nullableListOfNullables", }, - NullableMapOfNullables = new Dictionary() + NullableMapOfNullables = new Dictionary() { { "nullableMapOfNullables", diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Unit/MockServer/CreateUserTest.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Unit/MockServer/CreateUserTest.cs new file mode 100644 index 000000000000..2d0718a28b54 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Unit/MockServer/CreateUserTest.cs @@ -0,0 +1,88 @@ +using NUnit.Framework; +using SeedNullableOptional; +using SeedNullableOptional.Core; + +namespace SeedNullableOptional.Test.Unit.MockServer; + +[TestFixture] +public class CreateUserTest : BaseMockServerTest +{ + [NUnit.Framework.Test] + public async Task MockServerTest() + { + const string requestJson = """ + { + "username": "username", + "email": "email", + "phone": "phone", + "address": { + "street": "street", + "city": "city", + "state": "state", + "zipCode": "zipCode", + "country": "country", + "buildingId": "buildingId", + "tenantId": "tenantId" + } + } + """; + + const string mockResponse = """ + { + "id": "id", + "username": "username", + "email": "email", + "phone": "phone", + "createdAt": "2024-01-15T09:30:00.000Z", + "updatedAt": "2024-01-15T09:30:00.000Z", + "address": { + "street": "street", + "city": "city", + "state": "state", + "zipCode": "zipCode", + "country": "country", + "buildingId": "buildingId", + "tenantId": "tenantId" + } + } + """; + + Server + .Given( + WireMock + .RequestBuilders.Request.Create() + .WithPath("/api/users") + .UsingPost() + .WithBodyAsJson(requestJson) + ) + .RespondWith( + WireMock + .ResponseBuilders.Response.Create() + .WithStatusCode(200) + .WithBody(mockResponse) + ); + + var response = await Client.NullableOptional.CreateUserAsync( + new CreateUserRequest + { + Username = "username", + Email = "email", + Phone = "phone", + Address = new Address + { + Street = "street", + City = "city", + State = "state", + ZipCode = "zipCode", + Country = "country", + BuildingId = "buildingId", + TenantId = "tenantId", + }, + } + ); + Assert.That( + response, + Is.EqualTo(JsonUtils.Deserialize(mockResponse)).UsingDefaults() + ); + } +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Unit/MockServer/FilterByRoleTest.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Unit/MockServer/FilterByRoleTest.cs new file mode 100644 index 000000000000..9420dd698ff5 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Unit/MockServer/FilterByRoleTest.cs @@ -0,0 +1,83 @@ +using NUnit.Framework; +using SeedNullableOptional; +using SeedNullableOptional.Core; + +namespace SeedNullableOptional.Test.Unit.MockServer; + +[TestFixture] +public class FilterByRoleTest : BaseMockServerTest +{ + [NUnit.Framework.Test] + public async Task MockServerTest() + { + const string mockResponse = """ + [ + { + "id": "id", + "username": "username", + "email": "email", + "phone": "phone", + "createdAt": "2024-01-15T09:30:00.000Z", + "updatedAt": "2024-01-15T09:30:00.000Z", + "address": { + "street": "street", + "city": "city", + "state": "state", + "zipCode": "zipCode", + "country": "country", + "buildingId": "buildingId", + "tenantId": "tenantId" + } + }, + { + "id": "id", + "username": "username", + "email": "email", + "phone": "phone", + "createdAt": "2024-01-15T09:30:00.000Z", + "updatedAt": "2024-01-15T09:30:00.000Z", + "address": { + "street": "street", + "city": "city", + "state": "state", + "zipCode": "zipCode", + "country": "country", + "buildingId": "buildingId", + "tenantId": "tenantId" + } + } + ] + """; + + Server + .Given( + WireMock + .RequestBuilders.Request.Create() + .WithPath("/api/users/filter") + .WithParam("role", "ADMIN") + .WithParam("status", "active") + .WithParam("secondaryRole", "ADMIN") + .UsingGet() + ) + .RespondWith( + WireMock + .ResponseBuilders.Response.Create() + .WithStatusCode(200) + .WithBody(mockResponse) + ); + + var response = await Client.NullableOptional.FilterByRoleAsync( + new FilterByRoleRequest + { + Role = UserRole.Admin, + Status = UserStatus.Active, + SecondaryRole = UserRole.Admin, + } + ); + Assert.That( + response, + Is.EqualTo(JsonUtils.Deserialize>(mockResponse)) + .UsingDefaults() + ); + } +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Unit/MockServer/GetComplexProfileTest.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Unit/MockServer/GetComplexProfileTest.cs new file mode 100644 index 000000000000..38bb7713f310 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Unit/MockServer/GetComplexProfileTest.cs @@ -0,0 +1,143 @@ +using NUnit.Framework; +using SeedNullableOptional; +using SeedNullableOptional.Core; + +namespace SeedNullableOptional.Test.Unit.MockServer; + +[TestFixture] +public class GetComplexProfileTest : BaseMockServerTest +{ + [NUnit.Framework.Test] + public async Task MockServerTest() + { + const string mockResponse = """ + { + "id": "id", + "nullableRole": "ADMIN", + "optionalRole": "ADMIN", + "optionalNullableRole": "ADMIN", + "nullableStatus": "active", + "optionalStatus": "active", + "optionalNullableStatus": "active", + "nullableNotification": { + "type": "email", + "emailAddress": "emailAddress", + "subject": "subject", + "htmlContent": "htmlContent" + }, + "optionalNotification": { + "type": "email", + "emailAddress": "emailAddress", + "subject": "subject", + "htmlContent": "htmlContent" + }, + "optionalNullableNotification": { + "type": "email", + "emailAddress": "emailAddress", + "subject": "subject", + "htmlContent": "htmlContent" + }, + "nullableSearchResult": { + "type": "user", + "id": "id", + "username": "username", + "email": "email", + "phone": "phone", + "createdAt": "2024-01-15T09:30:00.000Z", + "updatedAt": "2024-01-15T09:30:00.000Z", + "address": { + "street": "street", + "city": "city", + "state": "state", + "zipCode": "zipCode", + "country": "country", + "buildingId": "buildingId", + "tenantId": "tenantId" + } + }, + "optionalSearchResult": { + "type": "user", + "id": "id", + "username": "username", + "email": "email", + "phone": "phone", + "createdAt": "2024-01-15T09:30:00.000Z", + "updatedAt": "2024-01-15T09:30:00.000Z", + "address": { + "street": "street", + "city": "city", + "state": "state", + "zipCode": "zipCode", + "country": "country", + "buildingId": "buildingId", + "tenantId": "tenantId" + } + }, + "nullableArray": [ + "nullableArray", + "nullableArray" + ], + "optionalArray": [ + "optionalArray", + "optionalArray" + ], + "optionalNullableArray": [ + "optionalNullableArray", + "optionalNullableArray" + ], + "nullableListOfNullables": [ + "nullableListOfNullables", + "nullableListOfNullables" + ], + "nullableMapOfNullables": { + "nullableMapOfNullables": { + "street": "street", + "city": "city", + "state": "state", + "zipCode": "zipCode", + "country": "country", + "buildingId": "buildingId", + "tenantId": "tenantId" + } + }, + "nullableListOfUnions": [ + { + "type": "email", + "emailAddress": "emailAddress", + "subject": "subject", + "htmlContent": "htmlContent" + }, + { + "type": "email", + "emailAddress": "emailAddress", + "subject": "subject", + "htmlContent": "htmlContent" + } + ], + "optionalMapOfEnums": { + "optionalMapOfEnums": "ADMIN" + } + } + """; + + Server + .Given( + WireMock + .RequestBuilders.Request.Create() + .WithPath("/api/profiles/complex/profileId") + .UsingGet() + ) + .RespondWith( + WireMock + .ResponseBuilders.Response.Create() + .WithStatusCode(200) + .WithBody(mockResponse) + ); + + var response = await Client.NullableOptional.GetComplexProfileAsync("profileId"); + Assert.That( + response, + Is.EqualTo(JsonUtils.Deserialize(mockResponse)).UsingDefaults() + ); + } +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Unit/MockServer/GetNotificationSettingsTest.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Unit/MockServer/GetNotificationSettingsTest.cs new file mode 100644 index 000000000000..b5fea202037d --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Unit/MockServer/GetNotificationSettingsTest.cs @@ -0,0 +1,42 @@ +using NUnit.Framework; +using SeedNullableOptional; +using SeedNullableOptional.Core; + +namespace SeedNullableOptional.Test.Unit.MockServer; + +[TestFixture] +public class GetNotificationSettingsTest : BaseMockServerTest +{ + [NUnit.Framework.Test] + public async Task MockServerTest() + { + const string mockResponse = """ + { + "type": "email", + "emailAddress": "emailAddress", + "subject": "subject", + "htmlContent": "htmlContent" + } + """; + + Server + .Given( + WireMock + .RequestBuilders.Request.Create() + .WithPath("/api/users/userId/notifications") + .UsingGet() + ) + .RespondWith( + WireMock + .ResponseBuilders.Response.Create() + .WithStatusCode(200) + .WithBody(mockResponse) + ); + + var response = await Client.NullableOptional.GetNotificationSettingsAsync("userId"); + Assert.That( + response, + Is.EqualTo(JsonUtils.Deserialize(mockResponse)).UsingDefaults() + ); + } +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Unit/MockServer/GetSearchResultsTest.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Unit/MockServer/GetSearchResultsTest.cs new file mode 100644 index 000000000000..bd53251bf1d2 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Unit/MockServer/GetSearchResultsTest.cs @@ -0,0 +1,96 @@ +using NUnit.Framework; +using SeedNullableOptional; +using SeedNullableOptional.Core; + +namespace SeedNullableOptional.Test.Unit.MockServer; + +[TestFixture] +public class GetSearchResultsTest : BaseMockServerTest +{ + [NUnit.Framework.Test] + public async Task MockServerTest() + { + const string requestJson = """ + { + "query": "query", + "filters": { + "filters": "filters" + }, + "includeTypes": [ + "includeTypes", + "includeTypes" + ] + } + """; + + const string mockResponse = """ + [ + { + "type": "user", + "id": "id", + "username": "username", + "email": "email", + "phone": "phone", + "createdAt": "2024-01-15T09:30:00.000Z", + "updatedAt": "2024-01-15T09:30:00.000Z", + "address": { + "street": "street", + "city": "city", + "state": "state", + "zipCode": "zipCode", + "country": "country", + "buildingId": "buildingId", + "tenantId": "tenantId" + } + }, + { + "type": "user", + "id": "id", + "username": "username", + "email": "email", + "phone": "phone", + "createdAt": "2024-01-15T09:30:00.000Z", + "updatedAt": "2024-01-15T09:30:00.000Z", + "address": { + "street": "street", + "city": "city", + "state": "state", + "zipCode": "zipCode", + "country": "country", + "buildingId": "buildingId", + "tenantId": "tenantId" + } + } + ] + """; + + Server + .Given( + WireMock + .RequestBuilders.Request.Create() + .WithPath("/api/search") + .UsingPost() + .WithBodyAsJson(requestJson) + ) + .RespondWith( + WireMock + .ResponseBuilders.Response.Create() + .WithStatusCode(200) + .WithBody(mockResponse) + ); + + var response = await Client.NullableOptional.GetSearchResultsAsync( + new SearchRequest + { + Query = "query", + Filters = new Dictionary() { { "filters", "filters" } }, + IncludeTypes = new List() { "includeTypes", "includeTypes" }, + } + ); + Assert.That( + response, + Is.EqualTo(JsonUtils.Deserialize?>(mockResponse)) + .UsingDefaults() + ); + } +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Unit/MockServer/GetUserTest.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Unit/MockServer/GetUserTest.cs new file mode 100644 index 000000000000..6a065e8f0de6 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Unit/MockServer/GetUserTest.cs @@ -0,0 +1,50 @@ +using NUnit.Framework; +using SeedNullableOptional; +using SeedNullableOptional.Core; + +namespace SeedNullableOptional.Test.Unit.MockServer; + +[TestFixture] +public class GetUserTest : BaseMockServerTest +{ + [NUnit.Framework.Test] + public async Task MockServerTest() + { + const string mockResponse = """ + { + "id": "id", + "username": "username", + "email": "email", + "phone": "phone", + "createdAt": "2024-01-15T09:30:00.000Z", + "updatedAt": "2024-01-15T09:30:00.000Z", + "address": { + "street": "street", + "city": "city", + "state": "state", + "zipCode": "zipCode", + "country": "country", + "buildingId": "buildingId", + "tenantId": "tenantId" + } + } + """; + + Server + .Given( + WireMock.RequestBuilders.Request.Create().WithPath("/api/users/userId").UsingGet() + ) + .RespondWith( + WireMock + .ResponseBuilders.Response.Create() + .WithStatusCode(200) + .WithBody(mockResponse) + ); + + var response = await Client.NullableOptional.GetUserAsync("userId"); + Assert.That( + response, + Is.EqualTo(JsonUtils.Deserialize(mockResponse)).UsingDefaults() + ); + } +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Unit/MockServer/ListUsersTest.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Unit/MockServer/ListUsersTest.cs new file mode 100644 index 000000000000..e160807c30da --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Unit/MockServer/ListUsersTest.cs @@ -0,0 +1,84 @@ +using NUnit.Framework; +using SeedNullableOptional; +using SeedNullableOptional.Core; + +namespace SeedNullableOptional.Test.Unit.MockServer; + +[TestFixture] +public class ListUsersTest : BaseMockServerTest +{ + [NUnit.Framework.Test] + public async Task MockServerTest() + { + const string mockResponse = """ + [ + { + "id": "id", + "username": "username", + "email": "email", + "phone": "phone", + "createdAt": "2024-01-15T09:30:00.000Z", + "updatedAt": "2024-01-15T09:30:00.000Z", + "address": { + "street": "street", + "city": "city", + "state": "state", + "zipCode": "zipCode", + "country": "country", + "buildingId": "buildingId", + "tenantId": "tenantId" + } + }, + { + "id": "id", + "username": "username", + "email": "email", + "phone": "phone", + "createdAt": "2024-01-15T09:30:00.000Z", + "updatedAt": "2024-01-15T09:30:00.000Z", + "address": { + "street": "street", + "city": "city", + "state": "state", + "zipCode": "zipCode", + "country": "country", + "buildingId": "buildingId", + "tenantId": "tenantId" + } + } + ] + """; + + Server + .Given( + WireMock + .RequestBuilders.Request.Create() + .WithPath("/api/users") + .WithParam("limit", "1") + .WithParam("offset", "1") + .WithParam("sortBy", "sortBy") + .UsingGet() + ) + .RespondWith( + WireMock + .ResponseBuilders.Response.Create() + .WithStatusCode(200) + .WithBody(mockResponse) + ); + + var response = await Client.NullableOptional.ListUsersAsync( + new ListUsersRequest + { + Limit = 1, + Offset = 1, + IncludeDeleted = true, + SortBy = "sortBy", + } + ); + Assert.That( + response, + Is.EqualTo(JsonUtils.Deserialize>(mockResponse)) + .UsingDefaults() + ); + } +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Unit/MockServer/SearchUsersTest.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Unit/MockServer/SearchUsersTest.cs new file mode 100644 index 000000000000..4dccf2f66edc --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Unit/MockServer/SearchUsersTest.cs @@ -0,0 +1,84 @@ +using NUnit.Framework; +using SeedNullableOptional; +using SeedNullableOptional.Core; + +namespace SeedNullableOptional.Test.Unit.MockServer; + +[TestFixture] +public class SearchUsersTest : BaseMockServerTest +{ + [NUnit.Framework.Test] + public async Task MockServerTest() + { + const string mockResponse = """ + [ + { + "id": "id", + "username": "username", + "email": "email", + "phone": "phone", + "createdAt": "2024-01-15T09:30:00.000Z", + "updatedAt": "2024-01-15T09:30:00.000Z", + "address": { + "street": "street", + "city": "city", + "state": "state", + "zipCode": "zipCode", + "country": "country", + "buildingId": "buildingId", + "tenantId": "tenantId" + } + }, + { + "id": "id", + "username": "username", + "email": "email", + "phone": "phone", + "createdAt": "2024-01-15T09:30:00.000Z", + "updatedAt": "2024-01-15T09:30:00.000Z", + "address": { + "street": "street", + "city": "city", + "state": "state", + "zipCode": "zipCode", + "country": "country", + "buildingId": "buildingId", + "tenantId": "tenantId" + } + } + ] + """; + + Server + .Given( + WireMock + .RequestBuilders.Request.Create() + .WithPath("/api/users/search") + .WithParam("query", "query") + .WithParam("department", "department") + .WithParam("role", "role") + .UsingGet() + ) + .RespondWith( + WireMock + .ResponseBuilders.Response.Create() + .WithStatusCode(200) + .WithBody(mockResponse) + ); + + var response = await Client.NullableOptional.SearchUsersAsync( + new SearchUsersRequest + { + Query = "query", + Department = "department", + Role = "role", + IsActive = true, + } + ); + Assert.That( + response, + Is.EqualTo(JsonUtils.Deserialize>(mockResponse)) + .UsingDefaults() + ); + } +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Unit/MockServer/TestDeserializationTest.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Unit/MockServer/TestDeserializationTest.cs new file mode 100644 index 000000000000..e15d25ca21e2 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Unit/MockServer/TestDeserializationTest.cs @@ -0,0 +1,225 @@ +using System.Globalization; +using NUnit.Framework; +using SeedNullableOptional; +using SeedNullableOptional.Core; + +namespace SeedNullableOptional.Test.Unit.MockServer; + +[TestFixture] +public class TestDeserializationTest : BaseMockServerTest +{ + [NUnit.Framework.Test] + public async Task MockServerTest() + { + const string requestJson = """ + { + "requiredString": "requiredString", + "nullableString": "nullableString", + "optionalString": "optionalString", + "optionalNullableString": "optionalNullableString", + "nullableEnum": "ADMIN", + "optionalEnum": "active", + "nullableUnion": { + "type": "email", + "emailAddress": "emailAddress", + "subject": "subject", + "htmlContent": "htmlContent" + }, + "optionalUnion": { + "type": "user", + "id": "id", + "username": "username", + "email": "email", + "phone": "phone", + "createdAt": "2024-01-15T09:30:00.000Z", + "updatedAt": "2024-01-15T09:30:00.000Z", + "address": { + "street": "street", + "city": "city", + "state": "state", + "zipCode": "zipCode", + "country": "country", + "buildingId": "buildingId", + "tenantId": "tenantId" + } + }, + "nullableList": [ + "nullableList", + "nullableList" + ], + "nullableMap": { + "nullableMap": 1 + }, + "nullableObject": { + "street": "street", + "city": "city", + "state": "state", + "zipCode": "zipCode", + "country": "country", + "buildingId": "buildingId", + "tenantId": "tenantId" + }, + "optionalObject": { + "id": "id", + "name": "name", + "domain": "domain", + "employeeCount": 1 + } + } + """; + + const string mockResponse = """ + { + "echo": { + "requiredString": "requiredString", + "nullableString": "nullableString", + "optionalString": "optionalString", + "optionalNullableString": "optionalNullableString", + "nullableEnum": "ADMIN", + "optionalEnum": "active", + "nullableUnion": { + "type": "email", + "emailAddress": "emailAddress", + "subject": "subject", + "htmlContent": "htmlContent" + }, + "optionalUnion": { + "type": "user", + "id": "id", + "username": "username", + "email": "email", + "phone": "phone", + "createdAt": "2024-01-15T09:30:00.000Z", + "updatedAt": "2024-01-15T09:30:00.000Z", + "address": { + "street": "street", + "city": "city", + "state": "state", + "zipCode": "zipCode", + "country": "country", + "buildingId": "buildingId", + "tenantId": "tenantId" + } + }, + "nullableList": [ + "nullableList", + "nullableList" + ], + "nullableMap": { + "nullableMap": 1 + }, + "nullableObject": { + "street": "street", + "city": "city", + "state": "state", + "zipCode": "zipCode", + "country": "country", + "buildingId": "buildingId", + "tenantId": "tenantId" + }, + "optionalObject": { + "id": "id", + "name": "name", + "domain": "domain", + "employeeCount": 1 + } + }, + "processedAt": "2024-01-15T09:30:00.000Z", + "nullCount": 1, + "presentFieldsCount": 1 + } + """; + + Server + .Given( + WireMock + .RequestBuilders.Request.Create() + .WithPath("/api/test/deserialization") + .UsingPost() + .WithBodyAsJson(requestJson) + ) + .RespondWith( + WireMock + .ResponseBuilders.Response.Create() + .WithStatusCode(200) + .WithBody(mockResponse) + ); + + var response = await Client.NullableOptional.TestDeserializationAsync( + new DeserializationTestRequest + { + RequiredString = "requiredString", + NullableString = "nullableString", + OptionalString = "optionalString", + OptionalNullableString = "optionalNullableString", + NullableEnum = UserRole.Admin, + OptionalEnum = UserStatus.Active, + NullableUnion = new NotificationMethod( + new NotificationMethod.Email( + new EmailNotification + { + EmailAddress = "emailAddress", + Subject = "subject", + HtmlContent = "htmlContent", + } + ) + ), + OptionalUnion = new SearchResult( + new SearchResult.User( + new UserResponse + { + Id = "id", + Username = "username", + Email = "email", + Phone = "phone", + CreatedAt = DateTime.Parse( + "2024-01-15T09:30:00.000Z", + null, + DateTimeStyles.AdjustToUniversal + ), + UpdatedAt = DateTime.Parse( + "2024-01-15T09:30:00.000Z", + null, + DateTimeStyles.AdjustToUniversal + ), + Address = new Address + { + Street = "street", + City = "city", + State = "state", + ZipCode = "zipCode", + Country = "country", + BuildingId = "buildingId", + TenantId = "tenantId", + }, + } + ) + ), + NullableList = new List() { "nullableList", "nullableList" }, + NullableMap = new Dictionary() { { "nullableMap", 1 } }, + NullableObject = new Address + { + Street = "street", + City = "city", + State = "state", + ZipCode = "zipCode", + Country = "country", + BuildingId = "buildingId", + TenantId = "tenantId", + }, + OptionalObject = new Organization + { + Id = "id", + Name = "name", + Domain = "domain", + EmployeeCount = 1, + }, + } + ); + Assert.That( + response, + Is.EqualTo(JsonUtils.Deserialize(mockResponse)) + .UsingDefaults() + ); + } +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Unit/MockServer/UpdateComplexProfileTest.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Unit/MockServer/UpdateComplexProfileTest.cs new file mode 100644 index 000000000000..cf4b0c7fa1c0 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Unit/MockServer/UpdateComplexProfileTest.cs @@ -0,0 +1,229 @@ +using System.Globalization; +using NUnit.Framework; +using SeedNullableOptional; +using SeedNullableOptional.Core; + +namespace SeedNullableOptional.Test.Unit.MockServer; + +[TestFixture] +public class UpdateComplexProfileTest : BaseMockServerTest +{ + [NUnit.Framework.Test] + public async Task MockServerTest() + { + const string requestJson = """ + { + "nullableRole": "ADMIN", + "nullableStatus": "active", + "nullableNotification": { + "type": "email", + "emailAddress": "emailAddress", + "subject": "subject", + "htmlContent": "htmlContent" + }, + "nullableSearchResult": { + "type": "user", + "id": "id", + "username": "username", + "email": "email", + "phone": "phone", + "createdAt": "2024-01-15T09:30:00.000Z", + "updatedAt": "2024-01-15T09:30:00.000Z", + "address": { + "street": "street", + "city": "city", + "state": "state", + "zipCode": "zipCode", + "country": "country", + "buildingId": "buildingId", + "tenantId": "tenantId" + } + }, + "nullableArray": [ + "nullableArray", + "nullableArray" + ] + } + """; + + const string mockResponse = """ + { + "id": "id", + "nullableRole": "ADMIN", + "optionalRole": "ADMIN", + "optionalNullableRole": "ADMIN", + "nullableStatus": "active", + "optionalStatus": "active", + "optionalNullableStatus": "active", + "nullableNotification": { + "type": "email", + "emailAddress": "emailAddress", + "subject": "subject", + "htmlContent": "htmlContent" + }, + "optionalNotification": { + "type": "email", + "emailAddress": "emailAddress", + "subject": "subject", + "htmlContent": "htmlContent" + }, + "optionalNullableNotification": { + "type": "email", + "emailAddress": "emailAddress", + "subject": "subject", + "htmlContent": "htmlContent" + }, + "nullableSearchResult": { + "type": "user", + "id": "id", + "username": "username", + "email": "email", + "phone": "phone", + "createdAt": "2024-01-15T09:30:00.000Z", + "updatedAt": "2024-01-15T09:30:00.000Z", + "address": { + "street": "street", + "city": "city", + "state": "state", + "zipCode": "zipCode", + "country": "country", + "buildingId": "buildingId", + "tenantId": "tenantId" + } + }, + "optionalSearchResult": { + "type": "user", + "id": "id", + "username": "username", + "email": "email", + "phone": "phone", + "createdAt": "2024-01-15T09:30:00.000Z", + "updatedAt": "2024-01-15T09:30:00.000Z", + "address": { + "street": "street", + "city": "city", + "state": "state", + "zipCode": "zipCode", + "country": "country", + "buildingId": "buildingId", + "tenantId": "tenantId" + } + }, + "nullableArray": [ + "nullableArray", + "nullableArray" + ], + "optionalArray": [ + "optionalArray", + "optionalArray" + ], + "optionalNullableArray": [ + "optionalNullableArray", + "optionalNullableArray" + ], + "nullableListOfNullables": [ + "nullableListOfNullables", + "nullableListOfNullables" + ], + "nullableMapOfNullables": { + "nullableMapOfNullables": { + "street": "street", + "city": "city", + "state": "state", + "zipCode": "zipCode", + "country": "country", + "buildingId": "buildingId", + "tenantId": "tenantId" + } + }, + "nullableListOfUnions": [ + { + "type": "email", + "emailAddress": "emailAddress", + "subject": "subject", + "htmlContent": "htmlContent" + }, + { + "type": "email", + "emailAddress": "emailAddress", + "subject": "subject", + "htmlContent": "htmlContent" + } + ], + "optionalMapOfEnums": { + "optionalMapOfEnums": "ADMIN" + } + } + """; + + Server + .Given( + WireMock + .RequestBuilders.Request.Create() + .WithPath("/api/profiles/complex/profileId") + .UsingPatch() + .WithBodyAsJson(requestJson) + ) + .RespondWith( + WireMock + .ResponseBuilders.Response.Create() + .WithStatusCode(200) + .WithBody(mockResponse) + ); + + var response = await Client.NullableOptional.UpdateComplexProfileAsync( + "profileId", + new UpdateComplexProfileRequest + { + NullableRole = UserRole.Admin, + NullableStatus = UserStatus.Active, + NullableNotification = new NotificationMethod( + new NotificationMethod.Email( + new EmailNotification + { + EmailAddress = "emailAddress", + Subject = "subject", + HtmlContent = "htmlContent", + } + ) + ), + NullableSearchResult = new SearchResult( + new SearchResult.User( + new UserResponse + { + Id = "id", + Username = "username", + Email = "email", + Phone = "phone", + CreatedAt = DateTime.Parse( + "2024-01-15T09:30:00.000Z", + null, + DateTimeStyles.AdjustToUniversal + ), + UpdatedAt = DateTime.Parse( + "2024-01-15T09:30:00.000Z", + null, + DateTimeStyles.AdjustToUniversal + ), + Address = new Address + { + Street = "street", + City = "city", + State = "state", + ZipCode = "zipCode", + Country = "country", + BuildingId = "buildingId", + TenantId = "tenantId", + }, + } + ) + ), + NullableArray = new List() { "nullableArray", "nullableArray" }, + } + ); + Assert.That( + response, + Is.EqualTo(JsonUtils.Deserialize(mockResponse)).UsingDefaults() + ); + } +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Unit/MockServer/UpdateTagsTest.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Unit/MockServer/UpdateTagsTest.cs new file mode 100644 index 000000000000..4beac53b18ef --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Unit/MockServer/UpdateTagsTest.cs @@ -0,0 +1,66 @@ +using NUnit.Framework; +using SeedNullableOptional; +using SeedNullableOptional.Core; + +namespace SeedNullableOptional.Test.Unit.MockServer; + +[TestFixture] +public class UpdateTagsTest : BaseMockServerTest +{ + [NUnit.Framework.Test] + public async Task MockServerTest() + { + const string requestJson = """ + { + "tags": [ + "tags", + "tags" + ], + "categories": [ + "categories", + "categories" + ], + "labels": [ + "labels", + "labels" + ] + } + """; + + const string mockResponse = """ + [ + "string", + "string" + ] + """; + + Server + .Given( + WireMock + .RequestBuilders.Request.Create() + .WithPath("/api/users/userId/tags") + .UsingPut() + .WithBodyAsJson(requestJson) + ) + .RespondWith( + WireMock + .ResponseBuilders.Response.Create() + .WithStatusCode(200) + .WithBody(mockResponse) + ); + + var response = await Client.NullableOptional.UpdateTagsAsync( + "userId", + new UpdateTagsRequest + { + Tags = new List() { "tags", "tags" }, + Categories = new List() { "categories", "categories" }, + Labels = new List() { "labels", "labels" }, + } + ); + Assert.That( + response, + Is.EqualTo(JsonUtils.Deserialize>(mockResponse)).UsingDefaults() + ); + } +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Unit/MockServer/UpdateUserTest.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Unit/MockServer/UpdateUserTest.cs new file mode 100644 index 000000000000..5a08c12c1f1f --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Unit/MockServer/UpdateUserTest.cs @@ -0,0 +1,89 @@ +using NUnit.Framework; +using SeedNullableOptional; +using SeedNullableOptional.Core; + +namespace SeedNullableOptional.Test.Unit.MockServer; + +[TestFixture] +public class UpdateUserTest : BaseMockServerTest +{ + [NUnit.Framework.Test] + public async Task MockServerTest() + { + const string requestJson = """ + { + "username": "username", + "email": "email", + "phone": "phone", + "address": { + "street": "street", + "city": "city", + "state": "state", + "zipCode": "zipCode", + "country": "country", + "buildingId": "buildingId", + "tenantId": "tenantId" + } + } + """; + + const string mockResponse = """ + { + "id": "id", + "username": "username", + "email": "email", + "phone": "phone", + "createdAt": "2024-01-15T09:30:00.000Z", + "updatedAt": "2024-01-15T09:30:00.000Z", + "address": { + "street": "street", + "city": "city", + "state": "state", + "zipCode": "zipCode", + "country": "country", + "buildingId": "buildingId", + "tenantId": "tenantId" + } + } + """; + + Server + .Given( + WireMock + .RequestBuilders.Request.Create() + .WithPath("/api/users/userId") + .UsingPatch() + .WithBodyAsJson(requestJson) + ) + .RespondWith( + WireMock + .ResponseBuilders.Response.Create() + .WithStatusCode(200) + .WithBody(mockResponse) + ); + + var response = await Client.NullableOptional.UpdateUserAsync( + "userId", + new UpdateUserRequest + { + Username = "username", + Email = "email", + Phone = "phone", + Address = new Address + { + Street = "street", + City = "city", + State = "state", + ZipCode = "zipCode", + Country = "country", + BuildingId = "buildingId", + TenantId = "tenantId", + }, + } + ); + Assert.That( + response, + Is.EqualTo(JsonUtils.Deserialize(mockResponse)).UsingDefaults() + ); + } +} diff --git a/seed/csharp-sdk/nullable/src/SeedNullable.Test/Utils/JsonElementComparer.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Utils/JsonElementComparer.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable.Test/Utils/JsonElementComparer.cs rename to seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Utils/JsonElementComparer.cs diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Utils/NUnitExtensions.cs similarity index 92% rename from seed/csharp-sdk/required-nullable/src/SeedApi.Test/Utils/NUnitExtensions.cs rename to seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/required-nullable/src/SeedApi.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/nullable/src/SeedNullable.Test/Utils/OneOfComparer.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Utils/OneOfComparer.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable.Test/Utils/OneOfComparer.cs rename to seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Utils/OneOfComparer.cs diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..74d67adc08e9 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedNullableOptional.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/nullable/src/SeedNullable.Test/Utils/ReadOnlyMemoryComparer.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Utils/ReadOnlyMemoryComparer.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable.Test/Utils/ReadOnlyMemoryComparer.cs rename to seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional.Test/Utils/ReadOnlyMemoryComparer.cs diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/ApiResponse.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/ApiResponse.cs new file mode 100644 index 000000000000..095b3322843e --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/ApiResponse.cs @@ -0,0 +1,13 @@ +using System.Net.Http; + +namespace SeedNullableOptional.Core; + +/// +/// The response object returned from the API. +/// +internal record ApiResponse +{ + internal required int StatusCode { get; init; } + + internal required HttpResponseMessage Raw { get; init; } +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/BaseRequest.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/BaseRequest.cs new file mode 100644 index 000000000000..6788ec460a94 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/BaseRequest.cs @@ -0,0 +1,63 @@ +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; + +namespace SeedNullableOptional.Core; + +internal abstract record BaseRequest +{ + internal required string BaseUrl { get; init; } + + internal required HttpMethod Method { get; init; } + + internal required string Path { get; init; } + + internal string? ContentType { get; init; } + + internal Dictionary Query { get; init; } = new(); + + internal Headers Headers { get; init; } = new(); + + internal IRequestOptions? Options { get; init; } + + internal abstract HttpContent? CreateContent(); + + protected static ( + Encoding encoding, + string? charset, + string mediaType + ) ParseContentTypeOrDefault( + string? contentType, + Encoding encodingFallback, + string mediaTypeFallback + ) + { + var encoding = encodingFallback; + var mediaType = mediaTypeFallback; + string? charset = null; + if (string.IsNullOrEmpty(contentType)) + { + return (encoding, charset, mediaType); + } + + if (!MediaTypeHeaderValue.TryParse(contentType, out var mediaTypeHeaderValue)) + { + return (encoding, charset, mediaType); + } + + if (!string.IsNullOrEmpty(mediaTypeHeaderValue.CharSet)) + { + charset = mediaTypeHeaderValue.CharSet; + encoding = Encoding.GetEncoding(mediaTypeHeaderValue.CharSet); + } + + if (!string.IsNullOrEmpty(mediaTypeHeaderValue.MediaType)) + { + mediaType = mediaTypeHeaderValue.MediaType; + } + + return (encoding, charset, mediaType); + } + + protected static Encoding Utf8NoBom => EncodingCache.Utf8NoBom; +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/CollectionItemSerializer.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/CollectionItemSerializer.cs new file mode 100644 index 000000000000..6072d44cd726 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/CollectionItemSerializer.cs @@ -0,0 +1,89 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedNullableOptional.Core; + +/// +/// Json collection converter. +/// +/// Type of item to convert. +/// Converter to use for individual items. +internal class CollectionItemSerializer + : JsonConverter> + where TConverterType : JsonConverter +{ + /// + /// Reads a json string and deserializes it into an object. + /// + /// Json reader. + /// Type to convert. + /// Serializer options. + /// Created object. + public override IEnumerable? Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return default; + } + + var jsonSerializerOptions = new JsonSerializerOptions(options); + jsonSerializerOptions.Converters.Clear(); + jsonSerializerOptions.Converters.Add(Activator.CreateInstance()); + + var returnValue = new List(); + + while (reader.TokenType != JsonTokenType.EndArray) + { + if (reader.TokenType != JsonTokenType.StartArray) + { + var item = (TDatatype)( + JsonSerializer.Deserialize(ref reader, typeof(TDatatype), jsonSerializerOptions) + ?? throw new global::System.Exception( + $"Failed to deserialize collection item of type {typeof(TDatatype)}" + ) + ); + returnValue.Add(item); + } + + reader.Read(); + } + + return returnValue; + } + + /// + /// Writes a json string. + /// + /// Json writer. + /// Value to write. + /// Serializer options. + public override void Write( + Utf8JsonWriter writer, + IEnumerable? value, + JsonSerializerOptions options + ) + { + if (value == null) + { + writer.WriteNullValue(); + return; + } + + var jsonSerializerOptions = new JsonSerializerOptions(options); + jsonSerializerOptions.Converters.Clear(); + jsonSerializerOptions.Converters.Add(Activator.CreateInstance()); + + writer.WriteStartArray(); + + foreach (var data in value) + { + JsonSerializer.Serialize(writer, data, jsonSerializerOptions); + } + + writer.WriteEndArray(); + } +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/Constants.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/Constants.cs new file mode 100644 index 000000000000..1c9b10bd2d41 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/Constants.cs @@ -0,0 +1,7 @@ +namespace SeedNullableOptional.Core; + +internal static class Constants +{ + public const string DateTimeFormat = "yyyy'-'MM'-'dd'T'HH':'mm':'ss.fffK"; + public const string DateFormat = "yyyy-MM-dd"; +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/DateOnlyConverter.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/DateOnlyConverter.cs new file mode 100644 index 000000000000..252525912a87 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/DateOnlyConverter.cs @@ -0,0 +1,747 @@ +// ReSharper disable All +#pragma warning disable + +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using global::System.Diagnostics; +using global::System.Diagnostics.CodeAnalysis; +using global::System.Globalization; +using global::System.Runtime.CompilerServices; +using global::System.Runtime.InteropServices; +using global::System.Text.Json; +using global::System.Text.Json.Serialization; + +// ReSharper disable SuggestVarOrType_SimpleTypes +// ReSharper disable SuggestVarOrType_BuiltInTypes + +namespace SeedNullableOptional.Core +{ + /// + /// Custom converter for handling the data type with the System.Text.Json library. + /// + /// + /// This class backported from: + /// + /// System.Text.Json.Serialization.Converters.DateOnlyConverter + /// + public sealed class DateOnlyConverter : JsonConverter + { + private const int FormatLength = 10; // YYYY-MM-DD + + private const int MaxEscapedFormatLength = + FormatLength * JsonConstants.MaxExpansionFactorWhileEscaping; + + /// + public override DateOnly Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType != JsonTokenType.String) + { + ThrowHelper.ThrowInvalidOperationException_ExpectedString(reader.TokenType); + } + + return ReadCore(ref reader); + } + + /// + public override DateOnly ReadAsPropertyName( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + Debug.Assert(reader.TokenType == JsonTokenType.PropertyName); + return ReadCore(ref reader); + } + + private static DateOnly ReadCore(ref Utf8JsonReader reader) + { + if ( + !JsonHelpers.IsInRangeInclusive( + reader.ValueLength(), + FormatLength, + MaxEscapedFormatLength + ) + ) + { + ThrowHelper.ThrowFormatException(DataType.DateOnly); + } + + scoped ReadOnlySpan source; + if (!reader.HasValueSequence && !reader.ValueIsEscaped) + { + source = reader.ValueSpan; + } + else + { + Span stackSpan = stackalloc byte[MaxEscapedFormatLength]; + int bytesWritten = reader.CopyString(stackSpan); + source = stackSpan.Slice(0, bytesWritten); + } + + if (!JsonHelpers.TryParseAsIso(source, out DateOnly value)) + { + ThrowHelper.ThrowFormatException(DataType.DateOnly); + } + + return value; + } + + /// + public override void Write( + Utf8JsonWriter writer, + DateOnly value, + JsonSerializerOptions options + ) + { +#if NET8_0_OR_GREATER + Span buffer = stackalloc byte[FormatLength]; +#else + Span buffer = stackalloc char[FormatLength]; +#endif + // ReSharper disable once RedundantAssignment + bool formattedSuccessfully = value.TryFormat( + buffer, + out int charsWritten, + "O".AsSpan(), + CultureInfo.InvariantCulture + ); + Debug.Assert(formattedSuccessfully && charsWritten == FormatLength); + writer.WriteStringValue(buffer); + } + + /// + public override void WriteAsPropertyName( + Utf8JsonWriter writer, + DateOnly value, + JsonSerializerOptions options + ) + { +#if NET8_0_OR_GREATER + Span buffer = stackalloc byte[FormatLength]; +#else + Span buffer = stackalloc char[FormatLength]; +#endif + // ReSharper disable once RedundantAssignment + bool formattedSuccessfully = value.TryFormat( + buffer, + out int charsWritten, + "O".AsSpan(), + CultureInfo.InvariantCulture + ); + Debug.Assert(formattedSuccessfully && charsWritten == FormatLength); + writer.WritePropertyName(buffer); + } + } + + internal static class JsonConstants + { + // The maximum number of fraction digits the Json DateTime parser allows + public const int DateTimeParseNumFractionDigits = 16; + + // In the worst case, an ASCII character represented as a single utf-8 byte could expand 6x when escaped. + public const int MaxExpansionFactorWhileEscaping = 6; + + // The largest fraction expressible by TimeSpan and DateTime formats + public const int MaxDateTimeFraction = 9_999_999; + + // TimeSpan and DateTime formats allow exactly up to many digits for specifying the fraction after the seconds. + public const int DateTimeNumFractionDigits = 7; + + public const byte UtcOffsetToken = (byte)'Z'; + + public const byte TimePrefix = (byte)'T'; + + public const byte Period = (byte)'.'; + + public const byte Hyphen = (byte)'-'; + + public const byte Colon = (byte)':'; + + public const byte Plus = (byte)'+'; + } + + // ReSharper disable SuggestVarOrType_Elsewhere + // ReSharper disable SuggestVarOrType_SimpleTypes + // ReSharper disable SuggestVarOrType_BuiltInTypes + + internal static class JsonHelpers + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsInRangeInclusive(int value, int lowerBound, int upperBound) => + (uint)(value - lowerBound) <= (uint)(upperBound - lowerBound); + + public static bool IsDigit(byte value) => (uint)(value - '0') <= '9' - '0'; + + [StructLayout(LayoutKind.Auto)] + private struct DateTimeParseData + { + public int Year; + public int Month; + public int Day; + public bool IsCalendarDateOnly; + public int Hour; + public int Minute; + public int Second; + public int Fraction; // This value should never be greater than 9_999_999. + public int OffsetHours; + public int OffsetMinutes; + + // ReSharper disable once NotAccessedField.Local + public byte OffsetToken; + } + + public static bool TryParseAsIso(ReadOnlySpan source, out DateOnly value) + { + if ( + TryParseDateTimeOffset(source, out DateTimeParseData parseData) + && parseData.IsCalendarDateOnly + && TryCreateDateTime(parseData, DateTimeKind.Unspecified, out DateTime dateTime) + ) + { + value = DateOnly.FromDateTime(dateTime); + return true; + } + + value = default; + return false; + } + + /// + /// ISO 8601 date time parser (ISO 8601-1:2019). + /// + /// The date/time to parse in UTF-8 format. + /// The parsed for the given . + /// + /// Supports extended calendar date (5.2.2.1) and complete (5.4.2.1) calendar date/time of day + /// representations with optional specification of seconds and fractional seconds. + /// + /// Times can be explicitly specified as UTC ("Z" - 5.3.3) or offsets from UTC ("+/-hh:mm" 5.3.4.2). + /// If unspecified they are considered to be local per spec. + /// + /// Examples: (TZD is either "Z" or hh:mm offset from UTC) + /// + /// YYYY-MM-DD (e.g. 1997-07-16) + /// YYYY-MM-DDThh:mm (e.g. 1997-07-16T19:20) + /// YYYY-MM-DDThh:mm:ss (e.g. 1997-07-16T19:20:30) + /// YYYY-MM-DDThh:mm:ss.s (e.g. 1997-07-16T19:20:30.45) + /// YYYY-MM-DDThh:mmTZD (e.g. 1997-07-16T19:20+01:00) + /// YYYY-MM-DDThh:mm:ssTZD (e.g. 1997-07-16T19:20:3001:00) + /// YYYY-MM-DDThh:mm:ss.sTZD (e.g. 1997-07-16T19:20:30.45Z) + /// + /// Generally speaking we always require the "extended" option when one exists (3.1.3.5). + /// The extended variants have separator characters between components ('-', ':', '.', etc.). + /// Spaces are not permitted. + /// + /// "true" if successfully parsed. + private static bool TryParseDateTimeOffset( + ReadOnlySpan source, + out DateTimeParseData parseData + ) + { + parseData = default; + + // too short datetime + Debug.Assert(source.Length >= 10); + + // Parse the calendar date + // ----------------------- + // ISO 8601-1:2019 5.2.2.1b "Calendar date complete extended format" + // [dateX] = [year]["-"][month]["-"][day] + // [year] = [YYYY] [0000 - 9999] (4.3.2) + // [month] = [MM] [01 - 12] (4.3.3) + // [day] = [DD] [01 - 28, 29, 30, 31] (4.3.4) + // + // Note: 5.2.2.2 "Representations with reduced precision" allows for + // just [year]["-"][month] (a) and just [year] (b), but we currently + // don't permit it. + + { + uint digit1 = source[0] - (uint)'0'; + uint digit2 = source[1] - (uint)'0'; + uint digit3 = source[2] - (uint)'0'; + uint digit4 = source[3] - (uint)'0'; + + if (digit1 > 9 || digit2 > 9 || digit3 > 9 || digit4 > 9) + { + return false; + } + + parseData.Year = (int)(digit1 * 1000 + digit2 * 100 + digit3 * 10 + digit4); + } + + if ( + source[4] != JsonConstants.Hyphen + || !TryGetNextTwoDigits(source.Slice(start: 5, length: 2), ref parseData.Month) + || source[7] != JsonConstants.Hyphen + || !TryGetNextTwoDigits(source.Slice(start: 8, length: 2), ref parseData.Day) + ) + { + return false; + } + + // We now have YYYY-MM-DD [dateX] + // ReSharper disable once ConvertIfStatementToSwitchStatement + if (source.Length == 10) + { + parseData.IsCalendarDateOnly = true; + return true; + } + + // Parse the time of day + // --------------------- + // + // ISO 8601-1:2019 5.3.1.2b "Local time of day complete extended format" + // [timeX] = ["T"][hour][":"][min][":"][sec] + // [hour] = [hh] [00 - 23] (4.3.8a) + // [minute] = [mm] [00 - 59] (4.3.9a) + // [sec] = [ss] [00 - 59, 60 with a leap second] (4.3.10a) + // + // ISO 8601-1:2019 5.3.3 "UTC of day" + // [timeX]["Z"] + // + // ISO 8601-1:2019 5.3.4.2 "Local time of day with the time shift between + // local timescale and UTC" (Extended format) + // + // [shiftX] = ["+"|"-"][hour][":"][min] + // + // Notes: + // + // "T" is optional per spec, but _only_ when times are used alone. In our + // case, we're reading out a complete date & time and as such require "T". + // (5.4.2.1b). + // + // For [timeX] We allow seconds to be omitted per 5.3.1.3a "Representations + // with reduced precision". 5.3.1.3b allows just specifying the hour, but + // we currently don't permit this. + // + // Decimal fractions are allowed for hours, minutes and seconds (5.3.14). + // We only allow fractions for seconds currently. Lower order components + // can't follow, i.e. you can have T23.3, but not T23.3:04. There must be + // one digit, but the max number of digits is implementation defined. We + // currently allow up to 16 digits of fractional seconds only. While we + // support 16 fractional digits we only parse the first seven, anything + // past that is considered a zero. This is to stay compatible with the + // DateTime implementation which is limited to this resolution. + + if (source.Length < 16) + { + // Source does not have enough characters for YYYY-MM-DDThh:mm + return false; + } + + // Parse THH:MM (e.g. "T10:32") + if ( + source[10] != JsonConstants.TimePrefix + || source[13] != JsonConstants.Colon + || !TryGetNextTwoDigits(source.Slice(start: 11, length: 2), ref parseData.Hour) + || !TryGetNextTwoDigits(source.Slice(start: 14, length: 2), ref parseData.Minute) + ) + { + return false; + } + + // We now have YYYY-MM-DDThh:mm + Debug.Assert(source.Length >= 16); + if (source.Length == 16) + { + return true; + } + + byte curByte = source[16]; + int sourceIndex = 17; + + // Either a TZD ['Z'|'+'|'-'] or a seconds separator [':'] is valid at this point + switch (curByte) + { + case JsonConstants.UtcOffsetToken: + parseData.OffsetToken = JsonConstants.UtcOffsetToken; + return sourceIndex == source.Length; + case JsonConstants.Plus: + case JsonConstants.Hyphen: + parseData.OffsetToken = curByte; + return ParseOffset(ref parseData, source.Slice(sourceIndex)); + case JsonConstants.Colon: + break; + default: + return false; + } + + // Try reading the seconds + if ( + source.Length < 19 + || !TryGetNextTwoDigits(source.Slice(start: 17, length: 2), ref parseData.Second) + ) + { + return false; + } + + // We now have YYYY-MM-DDThh:mm:ss + Debug.Assert(source.Length >= 19); + if (source.Length == 19) + { + return true; + } + + curByte = source[19]; + sourceIndex = 20; + + // Either a TZD ['Z'|'+'|'-'] or a seconds decimal fraction separator ['.'] is valid at this point + switch (curByte) + { + case JsonConstants.UtcOffsetToken: + parseData.OffsetToken = JsonConstants.UtcOffsetToken; + return sourceIndex == source.Length; + case JsonConstants.Plus: + case JsonConstants.Hyphen: + parseData.OffsetToken = curByte; + return ParseOffset(ref parseData, source.Slice(sourceIndex)); + case JsonConstants.Period: + break; + default: + return false; + } + + // Source does not have enough characters for second fractions (i.e. ".s") + // YYYY-MM-DDThh:mm:ss.s + if (source.Length < 21) + { + return false; + } + + // Parse fraction. This value should never be greater than 9_999_999 + int numDigitsRead = 0; + int fractionEnd = Math.Min( + sourceIndex + JsonConstants.DateTimeParseNumFractionDigits, + source.Length + ); + + while (sourceIndex < fractionEnd && IsDigit(curByte = source[sourceIndex])) + { + if (numDigitsRead < JsonConstants.DateTimeNumFractionDigits) + { + parseData.Fraction = parseData.Fraction * 10 + (int)(curByte - (uint)'0'); + numDigitsRead++; + } + + sourceIndex++; + } + + if (parseData.Fraction != 0) + { + while (numDigitsRead < JsonConstants.DateTimeNumFractionDigits) + { + parseData.Fraction *= 10; + numDigitsRead++; + } + } + + // We now have YYYY-MM-DDThh:mm:ss.s + Debug.Assert(sourceIndex <= source.Length); + if (sourceIndex == source.Length) + { + return true; + } + + curByte = source[sourceIndex++]; + + // TZD ['Z'|'+'|'-'] is valid at this point + switch (curByte) + { + case JsonConstants.UtcOffsetToken: + parseData.OffsetToken = JsonConstants.UtcOffsetToken; + return sourceIndex == source.Length; + case JsonConstants.Plus: + case JsonConstants.Hyphen: + parseData.OffsetToken = curByte; + return ParseOffset(ref parseData, source.Slice(sourceIndex)); + default: + return false; + } + + static bool ParseOffset(ref DateTimeParseData parseData, ReadOnlySpan offsetData) + { + // Parse the hours for the offset + if ( + offsetData.Length < 2 + || !TryGetNextTwoDigits(offsetData.Slice(0, 2), ref parseData.OffsetHours) + ) + { + return false; + } + + // We now have YYYY-MM-DDThh:mm:ss.s+|-hh + + if (offsetData.Length == 2) + { + // Just hours offset specified + return true; + } + + // Ensure we have enough for ":mm" + return offsetData.Length == 5 + && offsetData[2] == JsonConstants.Colon + && TryGetNextTwoDigits(offsetData.Slice(3), ref parseData.OffsetMinutes); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + // ReSharper disable once RedundantAssignment + private static bool TryGetNextTwoDigits(ReadOnlySpan source, ref int value) + { + Debug.Assert(source.Length == 2); + + uint digit1 = source[0] - (uint)'0'; + uint digit2 = source[1] - (uint)'0'; + + if (digit1 > 9 || digit2 > 9) + { + value = 0; + return false; + } + + value = (int)(digit1 * 10 + digit2); + return true; + } + + // The following methods are borrowed verbatim from src/Common/src/CoreLib/System/Buffers/Text/Utf8Parser/Utf8Parser.Date.Helpers.cs + + /// + /// Overflow-safe DateTime factory. + /// + private static bool TryCreateDateTime( + DateTimeParseData parseData, + DateTimeKind kind, + out DateTime value + ) + { + if (parseData.Year == 0) + { + value = default; + return false; + } + + Debug.Assert(parseData.Year <= 9999); // All of our callers to date parse the year from fixed 4-digit fields so this value is trusted. + + if ((uint)parseData.Month - 1 >= 12) + { + value = default; + return false; + } + + uint dayMinusOne = (uint)parseData.Day - 1; + if ( + dayMinusOne >= 28 + && dayMinusOne >= DateTime.DaysInMonth(parseData.Year, parseData.Month) + ) + { + value = default; + return false; + } + + if ((uint)parseData.Hour > 23) + { + value = default; + return false; + } + + if ((uint)parseData.Minute > 59) + { + value = default; + return false; + } + + // This needs to allow leap seconds when appropriate. + // See https://github.com/dotnet/runtime/issues/30135. + if ((uint)parseData.Second > 59) + { + value = default; + return false; + } + + Debug.Assert(parseData.Fraction is >= 0 and <= JsonConstants.MaxDateTimeFraction); // All of our callers to date parse the fraction from fixed 7-digit fields so this value is trusted. + + ReadOnlySpan days = DateTime.IsLeapYear(parseData.Year) + ? DaysToMonth366 + : DaysToMonth365; + int yearMinusOne = parseData.Year - 1; + int totalDays = + yearMinusOne * 365 + + yearMinusOne / 4 + - yearMinusOne / 100 + + yearMinusOne / 400 + + days[parseData.Month - 1] + + parseData.Day + - 1; + long ticks = totalDays * TimeSpan.TicksPerDay; + int totalSeconds = parseData.Hour * 3600 + parseData.Minute * 60 + parseData.Second; + ticks += totalSeconds * TimeSpan.TicksPerSecond; + ticks += parseData.Fraction; + value = new DateTime(ticks: ticks, kind: kind); + return true; + } + + private static ReadOnlySpan DaysToMonth365 => + [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334, 365]; + private static ReadOnlySpan DaysToMonth366 => + [0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335, 366]; + } + + internal static class ThrowHelper + { + private const string ExceptionSourceValueToRethrowAsJsonException = + "System.Text.Json.Rethrowable"; + + [DoesNotReturn] + public static void ThrowInvalidOperationException_ExpectedString(JsonTokenType tokenType) + { + throw GetInvalidOperationException("string", tokenType); + } + + public static void ThrowFormatException(DataType dataType) + { + throw new FormatException(SR.Format(SR.UnsupportedFormat, dataType)) + { + Source = ExceptionSourceValueToRethrowAsJsonException, + }; + } + + private static global::System.Exception GetInvalidOperationException( + string message, + JsonTokenType tokenType + ) + { + return GetInvalidOperationException(SR.Format(SR.InvalidCast, tokenType, message)); + } + + private static InvalidOperationException GetInvalidOperationException(string message) + { + return new InvalidOperationException(message) + { + Source = ExceptionSourceValueToRethrowAsJsonException, + }; + } + } + + internal static class Utf8JsonReaderExtensions + { + internal static int ValueLength(this Utf8JsonReader reader) => + reader.HasValueSequence + ? checked((int)reader.ValueSequence.Length) + : reader.ValueSpan.Length; + } + + internal enum DataType + { + TimeOnly, + DateOnly, + } + + [SuppressMessage("ReSharper", "InconsistentNaming")] + internal static class SR + { + private static readonly bool s_usingResourceKeys = + AppContext.TryGetSwitch( + "System.Resources.UseSystemResourceKeys", + out bool usingResourceKeys + ) && usingResourceKeys; + + public static string UnsupportedFormat => Strings.UnsupportedFormat; + + public static string InvalidCast => Strings.InvalidCast; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static string Format(string resourceFormat, object? p1) => + s_usingResourceKeys + ? string.Join(", ", resourceFormat, p1) + : string.Format(resourceFormat, p1); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static string Format(string resourceFormat, object? p1, object? p2) => + s_usingResourceKeys + ? string.Join(", ", resourceFormat, p1, p2) + : string.Format(resourceFormat, p1, p2); + } + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute( + "System.Resources.Tools.StronglyTypedResourceBuilder", + "17.0.0.0" + )] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Strings + { + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute( + "Microsoft.Performance", + "CA1811:AvoidUncalledPrivateCode" + )] + internal Strings() { } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute( + global::System.ComponentModel.EditorBrowsableState.Advanced + )] + internal static global::System.Resources.ResourceManager ResourceManager + { + get + { + if (object.ReferenceEquals(resourceMan, null)) + { + global::System.Resources.ResourceManager temp = + new global::System.Resources.ResourceManager( + "System.Text.Json.Resources.Strings", + typeof(Strings).Assembly + ); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute( + global::System.ComponentModel.EditorBrowsableState.Advanced + )] + internal static global::System.Globalization.CultureInfo Culture + { + get { return resourceCulture; } + set { resourceCulture = value; } + } + + /// + /// Looks up a localized string similar to Cannot get the value of a token type '{0}' as a {1}.. + /// + internal static string InvalidCast + { + get { return ResourceManager.GetString("InvalidCast", resourceCulture); } + } + + /// + /// Looks up a localized string similar to The JSON value is not in a supported {0} format.. + /// + internal static string UnsupportedFormat + { + get { return ResourceManager.GetString("UnsupportedFormat", resourceCulture); } + } + } +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/DateTimeSerializer.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/DateTimeSerializer.cs new file mode 100644 index 000000000000..4bbaeae6ac2b --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/DateTimeSerializer.cs @@ -0,0 +1,22 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedNullableOptional.Core; + +internal class DateTimeSerializer : JsonConverter +{ + public override DateTime Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + return DateTime.Parse(reader.GetString()!, null, DateTimeStyles.RoundtripKind); + } + + public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString(Constants.DateTimeFormat)); + } +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/EmptyRequest.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/EmptyRequest.cs new file mode 100644 index 000000000000..ee30b37a61f0 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/EmptyRequest.cs @@ -0,0 +1,11 @@ +using System.Net.Http; + +namespace SeedNullableOptional.Core; + +/// +/// The request object to send without a request body. +/// +internal record EmptyRequest : BaseRequest +{ + internal override HttpContent? CreateContent() => null; +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/EncodingCache.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/EncodingCache.cs new file mode 100644 index 000000000000..3009900676d4 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/EncodingCache.cs @@ -0,0 +1,11 @@ +using System.Text; + +namespace SeedNullableOptional.Core; + +internal static class EncodingCache +{ + internal static readonly Encoding Utf8NoBom = new UTF8Encoding( + encoderShouldEmitUTF8Identifier: false, + throwOnInvalidBytes: true + ); +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/Extensions.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/Extensions.cs new file mode 100644 index 000000000000..55fff021cbc3 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/Extensions.cs @@ -0,0 +1,55 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.Serialization; + +namespace SeedNullableOptional.Core; + +internal static class Extensions +{ + public static string Stringify(this Enum value) + { + var field = value.GetType().GetField(value.ToString()); + if (field != null) + { + var attribute = (EnumMemberAttribute?) + global::System.Attribute.GetCustomAttribute(field, typeof(EnumMemberAttribute)); + return attribute?.Value ?? value.ToString(); + } + return value.ToString(); + } + + /// + /// Asserts that a condition is true, throwing an exception with the specified message if it is false. + /// + /// The condition to assert. + /// The exception message if the assertion fails. + /// Thrown when the condition is false. + internal static void Assert(this object value, bool condition, string message) + { + if (!condition) + { + throw new global::System.Exception(message); + } + } + + /// + /// Asserts that a value is not null, throwing an exception with the specified message if it is null. + /// + /// The type of the value to assert. + /// The value to assert is not null. + /// The exception message if the assertion fails. + /// The non-null value. + /// Thrown when the value is null. + internal static TValue Assert( + this object _unused, + [NotNull] TValue? value, + string message + ) + where TValue : class + { + if (value == null) + { + throw new global::System.Exception(message); + } + return value; + } +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/FormUrlEncoder.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/FormUrlEncoder.cs new file mode 100644 index 000000000000..04083c3262f2 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/FormUrlEncoder.cs @@ -0,0 +1,33 @@ +using global::System.Net.Http; + +namespace SeedNullableOptional.Core; + +/// +/// Encodes an object into a form URL-encoded content. +/// +public static class FormUrlEncoder +{ + /// + /// Encodes an object into a form URL-encoded content using Deep Object notation. + /// + /// Object to form URL-encode. You can pass in an object or dictionary, but not lists, strings, or primitives. + /// Throws when passing in a list, a string, or a primitive value. + internal static FormUrlEncodedContent EncodeAsDeepObject(object value) => + new(QueryStringConverter.ToDeepObject(value)); + + /// + /// Encodes an object into a form URL-encoded content using Exploded Form notation. + /// + /// Object to form URL-encode. You can pass in an object or dictionary, but not lists, strings, or primitives. + /// Throws when passing in a list, a string, or a primitive value. + internal static FormUrlEncodedContent EncodeAsExplodedForm(object value) => + new(QueryStringConverter.ToExplodedForm(value)); + + /// + /// Encodes an object into a form URL-encoded content using Form notation without exploding parameters. + /// + /// Object to form URL-encode. You can pass in an object or dictionary, but not lists, strings, or primitives. + /// Throws when passing in a list, a string, or a primitive value. + internal static FormUrlEncodedContent EncodeAsForm(object value) => + new(QueryStringConverter.ToForm(value)); +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/HeaderValue.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/HeaderValue.cs new file mode 100644 index 000000000000..aaba93659484 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/HeaderValue.cs @@ -0,0 +1,41 @@ +using OneOf; + +namespace SeedNullableOptional.Core; + +internal sealed class HeaderValue( + OneOf< + string, + Func, + Func>, + Func> + > value +) + : OneOfBase< + string, + Func, + Func>, + Func> + >(value) +{ + public static implicit operator HeaderValue(string value) => new(value); + + public static implicit operator HeaderValue(Func value) => new(value); + + public static implicit operator HeaderValue( + Func> value + ) => new(value); + + public static implicit operator HeaderValue( + Func> value + ) => new(value); + + internal global::System.Threading.Tasks.ValueTask ResolveAsync() + { + return Match( + str => new global::System.Threading.Tasks.ValueTask(str), + syncFunc => new global::System.Threading.Tasks.ValueTask(syncFunc()), + valueTaskFunc => valueTaskFunc(), + taskFunc => new global::System.Threading.Tasks.ValueTask(taskFunc()) + ); + } +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/Headers.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/Headers.cs new file mode 100644 index 000000000000..e8d882973163 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/Headers.cs @@ -0,0 +1,28 @@ +namespace SeedNullableOptional.Core; + +/// +/// Represents the headers sent with the request. +/// +internal sealed class Headers : Dictionary +{ + internal Headers() { } + + /// + /// Initializes a new instance of the Headers class with the specified value. + /// + /// + internal Headers(Dictionary value) + { + foreach (var kvp in value) + { + this[kvp.Key] = new HeaderValue(kvp.Value); + } + } + + /// + /// Initializes a new instance of the Headers class with the specified value. + /// + /// + internal Headers(IEnumerable> value) + : base(value.ToDictionary(e => e.Key, e => e.Value)) { } +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/HttpMethodExtensions.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/HttpMethodExtensions.cs new file mode 100644 index 000000000000..f547c5d93548 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/HttpMethodExtensions.cs @@ -0,0 +1,8 @@ +using System.Net.Http; + +namespace SeedNullableOptional.Core; + +internal static class HttpMethodExtensions +{ + public static readonly HttpMethod Patch = new("PATCH"); +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/IIsRetryableContent.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/IIsRetryableContent.cs new file mode 100644 index 000000000000..d284a516cca8 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/IIsRetryableContent.cs @@ -0,0 +1,6 @@ +namespace SeedNullableOptional.Core; + +public interface IIsRetryableContent +{ + public bool IsRetryable { get; } +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/IRequestOptions.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/IRequestOptions.cs new file mode 100644 index 000000000000..be6981f59ffa --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/IRequestOptions.cs @@ -0,0 +1,88 @@ +namespace SeedNullableOptional.Core; + +internal interface IRequestOptions +{ + /// + /// The Base URL for the API. + /// + public string? BaseUrl { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } + + /// + /// The http client used to make requests. + /// + public HttpClient? HttpClient { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } + + /// + /// The http headers sent with the request. + /// + internal Headers Headers { get; init; } + + /// + /// Additional headers to be sent with the request. + /// Headers previously set with matching keys will be overwritten. + /// + public IEnumerable> AdditionalHeaders { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } + + /// + /// The http client used to make requests. + /// + public int? MaxRetries { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } + + /// + /// The timeout for the request. + /// + public TimeSpan? Timeout { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } + + /// + /// Additional query parameters sent with the request. + /// + public IEnumerable> AdditionalQueryParameters { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } + + /// + /// Additional body properties sent with the request. + /// This is only applied to JSON requests. + /// + public object? AdditionalBodyProperties { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/JsonAccessAttribute.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/JsonAccessAttribute.cs new file mode 100644 index 000000000000..ecf3c6637985 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/JsonAccessAttribute.cs @@ -0,0 +1,15 @@ +namespace SeedNullableOptional.Core; + +[global::System.AttributeUsage( + global::System.AttributeTargets.Property | global::System.AttributeTargets.Field +)] +internal class JsonAccessAttribute(JsonAccessType accessType) : global::System.Attribute +{ + internal JsonAccessType AccessType { get; init; } = accessType; +} + +internal enum JsonAccessType +{ + ReadOnly, + WriteOnly, +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/JsonConfiguration.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/JsonConfiguration.cs new file mode 100644 index 000000000000..77c217f702a5 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/JsonConfiguration.cs @@ -0,0 +1,251 @@ +using global::System.Reflection; +using global::System.Text.Json; +using global::System.Text.Json.Nodes; +using global::System.Text.Json.Serialization; +using global::System.Text.Json.Serialization.Metadata; + +namespace SeedNullableOptional.Core; + +internal static partial class JsonOptions +{ + internal static readonly JsonSerializerOptions JsonSerializerOptions; + + static JsonOptions() + { + var options = new JsonSerializerOptions + { + Converters = + { + new DateTimeSerializer(), +#if USE_PORTABLE_DATE_ONLY + new DateOnlyConverter(), +#endif + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, +#if DEBUG + WriteIndented = true, +#endif + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = new DefaultJsonTypeInfoResolver + { + Modifiers = + { + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, + }, + }, + }; + ConfigureJsonSerializerOptions(options); + JsonSerializerOptions = options; + } + + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); +} + +internal static class JsonUtils +{ + internal static string Serialize(T obj) => + JsonSerializer.Serialize(obj, JsonOptions.JsonSerializerOptions); + + internal static JsonElement SerializeToElement(T obj) => + JsonSerializer.SerializeToElement(obj, JsonOptions.JsonSerializerOptions); + + internal static JsonDocument SerializeToDocument(T obj) => + JsonSerializer.SerializeToDocument(obj, JsonOptions.JsonSerializerOptions); + + internal static JsonNode? SerializeToNode(T obj) => + JsonSerializer.SerializeToNode(obj, JsonOptions.JsonSerializerOptions); + + internal static byte[] SerializeToUtf8Bytes(T obj) => + JsonSerializer.SerializeToUtf8Bytes(obj, JsonOptions.JsonSerializerOptions); + + internal static string SerializeWithAdditionalProperties( + T obj, + object? additionalProperties = null + ) + { + if (additionalProperties == null) + { + return Serialize(obj); + } + var additionalPropertiesJsonNode = SerializeToNode(additionalProperties); + if (additionalPropertiesJsonNode is not JsonObject additionalPropertiesJsonObject) + { + throw new InvalidOperationException( + "The additional properties must serialize to a JSON object." + ); + } + var jsonNode = SerializeToNode(obj); + if (jsonNode is not JsonObject jsonObject) + { + throw new InvalidOperationException( + "The serialized object must be a JSON object to add properties." + ); + } + MergeJsonObjects(jsonObject, additionalPropertiesJsonObject); + return jsonObject.ToJsonString(JsonOptions.JsonSerializerOptions); + } + + private static void MergeJsonObjects(JsonObject baseObject, JsonObject overrideObject) + { + foreach (var property in overrideObject) + { + if (!baseObject.TryGetPropertyValue(property.Key, out JsonNode? existingValue)) + { + baseObject[property.Key] = + property.Value != null ? JsonNode.Parse(property.Value.ToJsonString()) : null; + continue; + } + if ( + existingValue is JsonObject nestedBaseObject + && property.Value is JsonObject nestedOverrideObject + ) + { + // If both values are objects, recursively merge them. + MergeJsonObjects(nestedBaseObject, nestedOverrideObject); + continue; + } + // Otherwise, the overrideObject takes precedence. + baseObject[property.Key] = + property.Value != null ? JsonNode.Parse(property.Value.ToJsonString()) : null; + } + } + + internal static T Deserialize(string json) => + JsonSerializer.Deserialize(json, JsonOptions.JsonSerializerOptions)!; +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/JsonRequest.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/JsonRequest.cs new file mode 100644 index 000000000000..19db0ea3967c --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/JsonRequest.cs @@ -0,0 +1,36 @@ +using System.Net.Http; + +namespace SeedNullableOptional.Core; + +/// +/// The request object to be sent for JSON APIs. +/// +internal record JsonRequest : BaseRequest +{ + internal object? Body { get; init; } + + internal override HttpContent? CreateContent() + { + if (Body is null && Options?.AdditionalBodyProperties is null) + { + return null; + } + + var (encoding, charset, mediaType) = ParseContentTypeOrDefault( + ContentType, + Utf8NoBom, + "application/json" + ); + var content = new StringContent( + JsonUtils.SerializeWithAdditionalProperties(Body, Options?.AdditionalBodyProperties), + encoding, + mediaType + ); + if (string.IsNullOrEmpty(charset) && content.Headers.ContentType is not null) + { + content.Headers.ContentType.CharSet = ""; + } + + return content; + } +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/MultipartFormRequest.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/MultipartFormRequest.cs new file mode 100644 index 000000000000..10238d643ecf --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/MultipartFormRequest.cs @@ -0,0 +1,294 @@ +using System.Net.Http; +using System.Net.Http.Headers; + +namespace SeedNullableOptional.Core; + +/// +/// The request object to be sent for multipart form data. +/// +internal record MultipartFormRequest : BaseRequest +{ + private readonly List> _partAdders = []; + + internal void AddJsonPart(string name, object? value) => AddJsonPart(name, value, null); + + internal void AddJsonPart(string name, object? value, string? contentType) + { + if (value is null) + { + return; + } + + _partAdders.Add(form => + { + var (encoding, charset, mediaType) = ParseContentTypeOrDefault( + contentType, + Utf8NoBom, + "application/json" + ); + var content = new StringContent(JsonUtils.Serialize(value), encoding, mediaType); + if (string.IsNullOrEmpty(charset) && content.Headers.ContentType is not null) + { + content.Headers.ContentType.CharSet = ""; + } + + form.Add(content, name); + }); + } + + internal void AddJsonParts(string name, IEnumerable? value) => + AddJsonParts(name, value, null); + + internal void AddJsonParts(string name, IEnumerable? value, string? contentType) + { + if (value is null) + { + return; + } + + foreach (var item in value) + { + AddJsonPart(name, item, contentType); + } + } + + internal void AddJsonParts(string name, IEnumerable? value) => + AddJsonParts(name, value, null); + + internal void AddJsonParts(string name, IEnumerable? value, string? contentType) + { + if (value is null) + { + return; + } + + foreach (var item in value) + { + AddJsonPart(name, item, contentType); + } + } + + internal void AddStringPart(string name, object? value) => AddStringPart(name, value, null); + + internal void AddStringPart(string name, object? value, string? contentType) + { + if (value is null) + { + return; + } + + AddStringPart(name, ValueConvert.ToString(value), contentType); + } + + internal void AddStringPart(string name, string? value) => AddStringPart(name, value, null); + + internal void AddStringPart(string name, string? value, string? contentType) + { + if (value is null) + { + return; + } + + _partAdders.Add(form => + { + var (encoding, charset, mediaType) = ParseContentTypeOrDefault( + contentType, + Utf8NoBom, + "text/plain" + ); + var content = new StringContent(value, encoding, mediaType); + if (string.IsNullOrEmpty(charset) && content.Headers.ContentType is not null) + { + content.Headers.ContentType.CharSet = ""; + } + + form.Add(content, name); + }); + } + + internal void AddStringParts(string name, IEnumerable? value) => + AddStringParts(name, value, null); + + internal void AddStringParts(string name, IEnumerable? value, string? contentType) + { + if (value is null) + { + return; + } + + AddStringPart(name, ValueConvert.ToString(value), contentType); + } + + internal void AddStringParts(string name, IEnumerable? value) => + AddStringParts(name, value, null); + + internal void AddStringParts(string name, IEnumerable? value, string? contentType) + { + if (value is null) + { + return; + } + + foreach (var item in value) + { + AddStringPart(name, item, contentType); + } + } + + internal void AddStreamPart(string name, Stream? stream, string? fileName) => + AddStreamPart(name, stream, fileName, null); + + internal void AddStreamPart(string name, Stream? stream, string? fileName, string? contentType) + { + if (stream is null) + { + return; + } + + _partAdders.Add(form => + { + var content = new StreamContent(stream) + { + Headers = + { + ContentType = MediaTypeHeaderValue.Parse( + contentType ?? "application/octet-stream" + ), + }, + }; + + if (fileName is not null) + { + form.Add(content, name, fileName); + } + else + { + form.Add(content, name); + } + }); + } + + internal void AddFileParameterPart(string name, Stream? stream) => + AddStreamPart(name, stream, null, null); + + internal void AddFileParameterPart(string name, FileParameter? file) => + AddFileParameterPart(name, file, null); + + internal void AddFileParameterPart( + string name, + FileParameter? file, + string? fallbackContentType + ) => + AddStreamPart(name, file?.Stream, file?.FileName, file?.ContentType ?? fallbackContentType); + + internal void AddFileParameterParts(string name, IEnumerable? files) => + AddFileParameterParts(name, files, null); + + internal void AddFileParameterParts( + string name, + IEnumerable? files, + string? fallbackContentType + ) + { + if (files is null) + { + return; + } + + foreach (var file in files) + { + AddFileParameterPart(name, file, fallbackContentType); + } + } + + internal void AddFormEncodedPart(string name, object? value) => + AddFormEncodedPart(name, value, null); + + internal void AddFormEncodedPart(string name, object? value, string? contentType) + { + if (value is null) + { + return; + } + + _partAdders.Add(form => + { + var content = FormUrlEncoder.EncodeAsForm(value); + if (!string.IsNullOrEmpty(contentType)) + { + content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType); + } + + form.Add(content, name); + }); + } + + internal void AddFormEncodedParts(string name, IEnumerable? value) => + AddFormEncodedParts(name, value, null); + + internal void AddFormEncodedParts(string name, IEnumerable? value, string? contentType) + { + if (value is null) + { + return; + } + + foreach (var item in value) + { + AddFormEncodedPart(name, item, contentType); + } + } + + internal void AddExplodedFormEncodedPart(string name, object? value) => + AddExplodedFormEncodedPart(name, value, null); + + internal void AddExplodedFormEncodedPart(string name, object? value, string? contentType) + { + if (value is null) + { + return; + } + + _partAdders.Add(form => + { + var content = FormUrlEncoder.EncodeAsExplodedForm(value); + if (!string.IsNullOrEmpty(contentType)) + { + content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType); + } + + form.Add(content, name); + }); + } + + internal void AddExplodedFormEncodedParts(string name, IEnumerable? value) => + AddExplodedFormEncodedParts(name, value, null); + + internal void AddExplodedFormEncodedParts( + string name, + IEnumerable? value, + string? contentType + ) + { + if (value is null) + { + return; + } + + foreach (var item in value) + { + AddExplodedFormEncodedPart(name, item, contentType); + } + } + + internal override HttpContent CreateContent() + { + var form = new MultipartFormDataContent(); + foreach (var adder in _partAdders) + { + adder(form); + } + + return form; + } +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/NullableAttribute.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/NullableAttribute.cs new file mode 100644 index 000000000000..e40ce4e90658 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedNullableOptional.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/OneOfSerializer.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/OneOfSerializer.cs new file mode 100644 index 000000000000..dde4c6c81ea9 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/OneOfSerializer.cs @@ -0,0 +1,91 @@ +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using OneOf; + +namespace SeedNullableOptional.Core; + +internal class OneOfSerializer : JsonConverter +{ + public override IOneOf? Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType is JsonTokenType.Null) + return default; + + foreach (var (type, cast) in GetOneOfTypes(typeToConvert)) + { + try + { + var readerCopy = reader; + var result = JsonSerializer.Deserialize(ref readerCopy, type, options); + reader.Skip(); + return (IOneOf)cast.Invoke(null, [result])!; + } + catch (JsonException) { } + } + + throw new JsonException( + $"Cannot deserialize into one of the supported types for {typeToConvert}" + ); + } + + public override void Write(Utf8JsonWriter writer, IOneOf value, JsonSerializerOptions options) + { + JsonSerializer.Serialize(writer, value.Value, options); + } + + private static (global::System.Type type, MethodInfo cast)[] GetOneOfTypes( + global::System.Type typeToConvert + ) + { + var type = typeToConvert; + if (Nullable.GetUnderlyingType(type) is { } underlyingType) + { + type = underlyingType; + } + + var casts = type.GetRuntimeMethods() + .Where(m => m.IsSpecialName && m.Name == "op_Implicit") + .ToArray(); + while (type != null) + { + if ( + type.IsGenericType + && (type.Name.StartsWith("OneOf`") || type.Name.StartsWith("OneOfBase`")) + ) + { + var genericArguments = type.GetGenericArguments(); + if (genericArguments.Length == 1) + { + return [(genericArguments[0], casts[0])]; + } + + // if object type is present, make sure it is last + var indexOfObjectType = Array.IndexOf(genericArguments, typeof(object)); + if (indexOfObjectType != -1 && genericArguments.Length - 1 != indexOfObjectType) + { + genericArguments = genericArguments + .OrderBy(t => t == typeof(object) ? 1 : 0) + .ToArray(); + } + + return genericArguments + .Select(t => (t, casts.First(c => c.GetParameters()[0].ParameterType == t))) + .ToArray(); + } + + type = type.BaseType; + } + + throw new InvalidOperationException($"{type} isn't OneOf or OneOfBase"); + } + + public override bool CanConvert(global::System.Type typeToConvert) + { + return typeof(IOneOf).IsAssignableFrom(typeToConvert); + } +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/Optional.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/Optional.cs new file mode 100644 index 000000000000..d22b62b85fb9 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedNullableOptional.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/OptionalAttribute.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..3d5638427001 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedNullableOptional.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/Public/AdditionalProperties.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/Public/AdditionalProperties.cs new file mode 100644 index 000000000000..2789b3ba86f6 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/Public/AdditionalProperties.cs @@ -0,0 +1,353 @@ +using global::System.Collections; +using global::System.Collections.ObjectModel; +using global::System.Text.Json; +using global::System.Text.Json.Nodes; +using SeedNullableOptional.Core; + +namespace SeedNullableOptional; + +public record ReadOnlyAdditionalProperties : ReadOnlyAdditionalProperties +{ + internal ReadOnlyAdditionalProperties() { } + + internal ReadOnlyAdditionalProperties(IDictionary properties) + : base(properties) { } +} + +public record ReadOnlyAdditionalProperties : IReadOnlyDictionary +{ + private readonly Dictionary _extensionData = new(); + private readonly Dictionary _convertedCache = new(); + + internal ReadOnlyAdditionalProperties() + { + _extensionData = new Dictionary(); + _convertedCache = new Dictionary(); + } + + internal ReadOnlyAdditionalProperties(IDictionary properties) + { + _extensionData = new Dictionary(properties.Count); + _convertedCache = new Dictionary(properties.Count); + foreach (var kvp in properties) + { + if (kvp.Value is JsonElement element) + { + _extensionData.Add(kvp.Key, element); + } + else + { + _extensionData[kvp.Key] = JsonUtils.SerializeToElement(kvp.Value); + } + + _convertedCache[kvp.Key] = kvp.Value; + } + } + + private static T ConvertToT(JsonElement value) + { + if (typeof(T) == typeof(JsonElement)) + { + return (T)(object)value; + } + + return value.Deserialize(JsonOptions.JsonSerializerOptions)!; + } + + internal void CopyFromExtensionData(IDictionary extensionData) + { + _extensionData.Clear(); + _convertedCache.Clear(); + foreach (var kvp in extensionData) + { + _extensionData[kvp.Key] = kvp.Value; + if (kvp.Value is T value) + { + _convertedCache[kvp.Key] = value; + } + } + } + + private T GetCached(string key) + { + if (_convertedCache.TryGetValue(key, out var cached)) + { + return cached; + } + + var value = ConvertToT(_extensionData[key]); + _convertedCache[key] = value; + return value; + } + + public IEnumerator> GetEnumerator() + { + return _extensionData + .Select(kvp => new KeyValuePair(kvp.Key, GetCached(kvp.Key))) + .GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + public int Count => _extensionData.Count; + + public bool ContainsKey(string key) => _extensionData.ContainsKey(key); + + public bool TryGetValue(string key, out T value) + { + if (_convertedCache.TryGetValue(key, out value!)) + { + return true; + } + + if (_extensionData.TryGetValue(key, out var element)) + { + value = ConvertToT(element); + _convertedCache[key] = value; + return true; + } + + return false; + } + + public T this[string key] => GetCached(key); + + public IEnumerable Keys => _extensionData.Keys; + + public IEnumerable Values => Keys.Select(GetCached); +} + +public record AdditionalProperties : AdditionalProperties +{ + public AdditionalProperties() { } + + public AdditionalProperties(IDictionary properties) + : base(properties) { } +} + +public record AdditionalProperties : IDictionary +{ + private readonly Dictionary _extensionData; + private readonly Dictionary _convertedCache; + + public AdditionalProperties() + { + _extensionData = new Dictionary(); + _convertedCache = new Dictionary(); + } + + public AdditionalProperties(IDictionary properties) + { + _extensionData = new Dictionary(properties.Count); + _convertedCache = new Dictionary(properties.Count); + foreach (var kvp in properties) + { + _extensionData[kvp.Key] = kvp.Value; + _convertedCache[kvp.Key] = kvp.Value; + } + } + + private static T ConvertToT(object? extensionDataValue) + { + return extensionDataValue switch + { + T value => value, + JsonElement jsonElement => jsonElement.Deserialize( + JsonOptions.JsonSerializerOptions + )!, + JsonNode jsonNode => jsonNode.Deserialize(JsonOptions.JsonSerializerOptions)!, + _ => JsonUtils + .SerializeToElement(extensionDataValue) + .Deserialize(JsonOptions.JsonSerializerOptions)!, + }; + } + + internal void CopyFromExtensionData(IDictionary extensionData) + { + _extensionData.Clear(); + _convertedCache.Clear(); + foreach (var kvp in extensionData) + { + _extensionData[kvp.Key] = kvp.Value; + if (kvp.Value is T value) + { + _convertedCache[kvp.Key] = value; + } + } + } + + internal void CopyToExtensionData(IDictionary extensionData) + { + extensionData.Clear(); + foreach (var kvp in _extensionData) + { + extensionData[kvp.Key] = kvp.Value; + } + } + + public JsonObject ToJsonObject() => + ( + JsonUtils.SerializeToNode(_extensionData) + ?? throw new InvalidOperationException( + "Failed to serialize AdditionalProperties to JSON Node." + ) + ).AsObject(); + + public JsonNode ToJsonNode() => + JsonUtils.SerializeToNode(_extensionData) + ?? throw new InvalidOperationException( + "Failed to serialize AdditionalProperties to JSON Node." + ); + + public JsonElement ToJsonElement() => JsonUtils.SerializeToElement(_extensionData); + + public JsonDocument ToJsonDocument() => JsonUtils.SerializeToDocument(_extensionData); + + public IReadOnlyDictionary ToJsonElementDictionary() + { + return new ReadOnlyDictionary( + _extensionData.ToDictionary( + kvp => kvp.Key, + kvp => + { + if (kvp.Value is JsonElement jsonElement) + { + return jsonElement; + } + + return JsonUtils.SerializeToElement(kvp.Value); + } + ) + ); + } + + public ICollection Keys => _extensionData.Keys; + + public ICollection Values + { + get + { + var values = new T[_extensionData.Count]; + var i = 0; + foreach (var key in Keys) + { + values[i++] = GetCached(key); + } + + return values; + } + } + + private T GetCached(string key) + { + if (_convertedCache.TryGetValue(key, out var value)) + { + return value; + } + + value = ConvertToT(_extensionData[key]); + _convertedCache.Add(key, value); + return value; + } + + private void SetCached(string key, T value) + { + _extensionData[key] = value; + _convertedCache[key] = value; + } + + private void AddCached(string key, T value) + { + _extensionData.Add(key, value); + _convertedCache.Add(key, value); + } + + private bool RemoveCached(string key) + { + var isRemoved = _extensionData.Remove(key); + _convertedCache.Remove(key); + return isRemoved; + } + + public int Count => _extensionData.Count; + public bool IsReadOnly => false; + + public T this[string key] + { + get => GetCached(key); + set => SetCached(key, value); + } + + public void Add(string key, T value) => AddCached(key, value); + + public void Add(KeyValuePair item) => AddCached(item.Key, item.Value); + + public bool Remove(string key) => RemoveCached(key); + + public bool Remove(KeyValuePair item) => RemoveCached(item.Key); + + public bool ContainsKey(string key) => _extensionData.ContainsKey(key); + + public bool Contains(KeyValuePair item) + { + return _extensionData.ContainsKey(item.Key) + && EqualityComparer.Default.Equals(GetCached(item.Key), item.Value); + } + + public bool TryGetValue(string key, out T value) + { + if (_convertedCache.TryGetValue(key, out value!)) + { + return true; + } + + if (_extensionData.TryGetValue(key, out var extensionDataValue)) + { + value = ConvertToT(extensionDataValue); + _convertedCache[key] = value; + return true; + } + + return false; + } + + public void Clear() + { + _extensionData.Clear(); + _convertedCache.Clear(); + } + + public void CopyTo(KeyValuePair[] array, int arrayIndex) + { + if (array is null) + { + throw new ArgumentNullException(nameof(array)); + } + + if (arrayIndex < 0 || arrayIndex > array.Length) + { + throw new ArgumentOutOfRangeException(nameof(arrayIndex)); + } + + if (array.Length - arrayIndex < _extensionData.Count) + { + throw new ArgumentException( + "The array does not have enough space to copy the elements." + ); + } + + foreach (var kvp in _extensionData) + { + array[arrayIndex++] = new KeyValuePair(kvp.Key, GetCached(kvp.Key)); + } + } + + public IEnumerator> GetEnumerator() + { + return _extensionData + .Select(kvp => new KeyValuePair(kvp.Key, GetCached(kvp.Key))) + .GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/Public/ClientOptions.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/Public/ClientOptions.cs new file mode 100644 index 000000000000..564ca1f77666 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/Public/ClientOptions.cs @@ -0,0 +1,83 @@ +using SeedNullableOptional.Core; + +namespace SeedNullableOptional; + +[Serializable] +public partial class ClientOptions +{ + /// + /// The http headers sent with the request. + /// + internal Headers Headers { get; init; } = new(); + + /// + /// The Base URL for the API. + /// + public string BaseUrl { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } = ""; + + /// + /// The http client used to make requests. + /// + public HttpClient HttpClient { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } = new HttpClient(); + + /// + /// Additional headers to be sent with HTTP requests. + /// Headers with matching keys will be overwritten by headers set on the request. + /// + public IEnumerable> AdditionalHeaders { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } = []; + + /// + /// The http client used to make requests. + /// + public int MaxRetries { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } = 2; + + /// + /// The timeout for the request. + /// + public TimeSpan Timeout { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } = TimeSpan.FromSeconds(30); + + /// + /// Clones this and returns a new instance + /// + internal ClientOptions Clone() + { + return new ClientOptions + { + BaseUrl = BaseUrl, + HttpClient = HttpClient, + MaxRetries = MaxRetries, + Timeout = Timeout, + Headers = new Headers(new Dictionary(Headers)), + }; + } +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/Public/FileParameter.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/Public/FileParameter.cs new file mode 100644 index 000000000000..29a8310bc96e --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/Public/FileParameter.cs @@ -0,0 +1,63 @@ +namespace SeedNullableOptional; + +/// +/// File parameter for uploading files. +/// +public record FileParameter : IDisposable +#if NET6_0_OR_GREATER + , IAsyncDisposable +#endif +{ + private bool _disposed; + + /// + /// The name of the file to be uploaded. + /// + public string? FileName { get; set; } + + /// + /// The content type of the file to be uploaded. + /// + public string? ContentType { get; set; } + + /// + /// The content of the file to be uploaded. + /// + public required Stream Stream { get; set; } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + protected virtual void Dispose(bool disposing) + { + if (_disposed) + return; + if (disposing) + { + Stream.Dispose(); + } + + _disposed = true; + } + +#if NET6_0_OR_GREATER + /// + public async ValueTask DisposeAsync() + { + if (!_disposed) + { + await Stream.DisposeAsync().ConfigureAwait(false); + _disposed = true; + } + + GC.SuppressFinalize(this); + } +#endif + + public static implicit operator FileParameter(Stream stream) => new() { Stream = stream }; +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/Public/RequestOptions.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/Public/RequestOptions.cs new file mode 100644 index 000000000000..985f3ca54b35 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/Public/RequestOptions.cs @@ -0,0 +1,91 @@ +using SeedNullableOptional.Core; + +namespace SeedNullableOptional; + +[Serializable] +public partial class RequestOptions : IRequestOptions +{ + /// + /// The http headers sent with the request. + /// + Headers IRequestOptions.Headers { get; init; } = new(); + + /// + /// The Base URL for the API. + /// + public string? BaseUrl { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } + + /// + /// The http client used to make requests. + /// + public HttpClient? HttpClient { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } + + /// + /// Additional headers to be sent with the request. + /// Headers previously set with matching keys will be overwritten. + /// + public IEnumerable> AdditionalHeaders { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } = []; + + /// + /// The http client used to make requests. + /// + public int? MaxRetries { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } + + /// + /// The timeout for the request. + /// + public TimeSpan? Timeout { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } + + /// + /// Additional query parameters sent with the request. + /// + public IEnumerable> AdditionalQueryParameters { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } = Enumerable.Empty>(); + + /// + /// Additional body properties sent with the request. + /// This is only applied to JSON requests. + /// + public object? AdditionalBodyProperties { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/Public/SeedNullableOptionalApiException.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/Public/SeedNullableOptionalApiException.cs new file mode 100644 index 000000000000..c0ebc6adfbc7 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/Public/SeedNullableOptionalApiException.cs @@ -0,0 +1,18 @@ +namespace SeedNullableOptional; + +/// +/// This exception type will be thrown for any non-2XX API responses. +/// +public class SeedNullableOptionalApiException(string message, int statusCode, object body) + : SeedNullableOptionalException(message) +{ + /// + /// The error code of the response that triggered the exception. + /// + public int StatusCode => statusCode; + + /// + /// The body of the response that triggered the exception. + /// + public object Body => body; +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/Public/SeedNullableOptionalException.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/Public/SeedNullableOptionalException.cs new file mode 100644 index 000000000000..da4dc400f1b3 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/Public/SeedNullableOptionalException.cs @@ -0,0 +1,7 @@ +namespace SeedNullableOptional; + +/// +/// Base exception class for all exceptions thrown by the SDK. +/// +public class SeedNullableOptionalException(string message, Exception? innerException = null) + : Exception(message, innerException); diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/Public/Version.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/Public/Version.cs new file mode 100644 index 000000000000..25d42d7dd6d8 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/Public/Version.cs @@ -0,0 +1,7 @@ +namespace SeedNullableOptional; + +[Serializable] +internal class Version +{ + public const string Current = "0.0.1"; +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/QueryStringConverter.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/QueryStringConverter.cs new file mode 100644 index 000000000000..2720ceef092c --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/QueryStringConverter.cs @@ -0,0 +1,229 @@ +using global::System.Text.Json; + +namespace SeedNullableOptional.Core; + +/// +/// Converts an object into a query string collection. +/// +internal static class QueryStringConverter +{ + /// + /// Converts an object into a query string collection using Deep Object notation. + /// + /// Object to form URL-encode. You can pass in an object or dictionary, but not lists, strings, or primitives. + /// Throws when passing in a list, a string, or a primitive value. + /// A collection of key value pairs. The keys and values are not URL encoded. + internal static IEnumerable> ToDeepObject(object value) + { + var queryCollection = new List>(); + var json = JsonUtils.SerializeToElement(value); + AssertRootJson(json); + JsonToDeepObject(json, "", queryCollection); + return queryCollection; + } + + /// + /// Converts an object into a query string collection using Exploded Form notation. + /// + /// Object to form URL-encode. You can pass in an object or dictionary, but not lists, strings, or primitives. + /// Throws when passing in a list, a string, or a primitive value. + /// A collection of key value pairs. The keys and values are not URL encoded. + internal static IEnumerable> ToExplodedForm(object value) + { + var queryCollection = new List>(); + var json = JsonUtils.SerializeToElement(value); + AssertRootJson(json); + JsonToFormExploded(json, "", queryCollection); + return queryCollection; + } + + /// + /// Converts an object into a query string collection using Form notation without exploding parameters. + /// + /// Object to form URL-encode. You can pass in an object or dictionary, but not lists, strings, or primitives. + /// Throws when passing in a list, a string, or a primitive value. + /// A collection of key value pairs. The keys and values are not URL encoded. + internal static IEnumerable> ToForm(object value) + { + var queryCollection = new List>(); + var json = JsonUtils.SerializeToElement(value); + AssertRootJson(json); + JsonToForm(json, "", queryCollection); + return queryCollection; + } + + private static void AssertRootJson(JsonElement json) + { + switch (json.ValueKind) + { + case JsonValueKind.Object: + break; + case JsonValueKind.Array: + case JsonValueKind.Undefined: + case JsonValueKind.String: + case JsonValueKind.Number: + case JsonValueKind.True: + case JsonValueKind.False: + case JsonValueKind.Null: + default: + throw new global::System.Exception( + $"Only objects can be converted to query string collections. Given type is {json.ValueKind}." + ); + } + } + + private static void JsonToForm( + JsonElement element, + string prefix, + List> parameters + ) + { + switch (element.ValueKind) + { + case JsonValueKind.Object: + foreach (var property in element.EnumerateObject()) + { + var newPrefix = string.IsNullOrEmpty(prefix) + ? property.Name + : $"{prefix}[{property.Name}]"; + + JsonToForm(property.Value, newPrefix, parameters); + } + break; + case JsonValueKind.Array: + var arrayValues = element.EnumerateArray().Select(ValueToString).ToArray(); + parameters.Add( + new KeyValuePair(prefix, string.Join(",", arrayValues)) + ); + break; + case JsonValueKind.Null: + break; + case JsonValueKind.Undefined: + case JsonValueKind.String: + case JsonValueKind.Number: + case JsonValueKind.True: + case JsonValueKind.False: + default: + parameters.Add(new KeyValuePair(prefix, ValueToString(element))); + break; + } + } + + private static void JsonToFormExploded( + JsonElement element, + string prefix, + List> parameters + ) + { + switch (element.ValueKind) + { + case JsonValueKind.Object: + foreach (var property in element.EnumerateObject()) + { + var newPrefix = string.IsNullOrEmpty(prefix) + ? property.Name + : $"{prefix}[{property.Name}]"; + + JsonToFormExploded(property.Value, newPrefix, parameters); + } + + break; + case JsonValueKind.Array: + foreach (var item in element.EnumerateArray()) + { + if ( + item.ValueKind != JsonValueKind.Object + && item.ValueKind != JsonValueKind.Array + ) + { + parameters.Add( + new KeyValuePair(prefix, ValueToString(item)) + ); + } + else + { + JsonToFormExploded(item, prefix, parameters); + } + } + + break; + case JsonValueKind.Null: + break; + case JsonValueKind.Undefined: + case JsonValueKind.String: + case JsonValueKind.Number: + case JsonValueKind.True: + case JsonValueKind.False: + default: + parameters.Add(new KeyValuePair(prefix, ValueToString(element))); + break; + } + } + + private static void JsonToDeepObject( + JsonElement element, + string prefix, + List> parameters + ) + { + switch (element.ValueKind) + { + case JsonValueKind.Object: + foreach (var property in element.EnumerateObject()) + { + var newPrefix = string.IsNullOrEmpty(prefix) + ? property.Name + : $"{prefix}[{property.Name}]"; + + JsonToDeepObject(property.Value, newPrefix, parameters); + } + + break; + case JsonValueKind.Array: + var index = 0; + foreach (var item in element.EnumerateArray()) + { + var newPrefix = $"{prefix}[{index++}]"; + + if ( + item.ValueKind != JsonValueKind.Object + && item.ValueKind != JsonValueKind.Array + ) + { + parameters.Add( + new KeyValuePair(newPrefix, ValueToString(item)) + ); + } + else + { + JsonToDeepObject(item, newPrefix, parameters); + } + } + + break; + case JsonValueKind.Null: + break; + case JsonValueKind.Undefined: + case JsonValueKind.String: + case JsonValueKind.Number: + case JsonValueKind.True: + case JsonValueKind.False: + default: + parameters.Add(new KeyValuePair(prefix, ValueToString(element))); + break; + } + } + + private static string ValueToString(JsonElement element) + { + return element.ValueKind switch + { + JsonValueKind.String => element.GetString() ?? "", + JsonValueKind.Number => element.GetRawText(), + JsonValueKind.True => "true", + JsonValueKind.False => "false", + JsonValueKind.Null => "", + _ => element.GetRawText(), + }; + } +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/RawClient.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/RawClient.cs new file mode 100644 index 000000000000..79943954afea --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/RawClient.cs @@ -0,0 +1,507 @@ +using global::System.Net.Http; +using global::System.Net.Http.Headers; +using global::System.Text; +using SystemTask = global::System.Threading.Tasks.Task; + +namespace SeedNullableOptional.Core; + +/// +/// Utility class for making raw HTTP requests to the API. +/// +internal partial class RawClient(ClientOptions clientOptions) +{ + private const int MaxRetryDelayMs = 60000; + private const double JitterFactor = 0.2; +#if NET6_0_OR_GREATER + // Use Random.Shared for thread-safe random number generation on .NET 6+ +#else + private static readonly object JitterLock = new(); + private static readonly Random JitterRandom = new(); +#endif + internal int BaseRetryDelay { get; set; } = 1000; + + /// + /// The client options applied on every request. + /// + internal readonly ClientOptions Options = clientOptions; + + [Obsolete("Use SendRequestAsync instead.")] + internal global::System.Threading.Tasks.Task MakeRequestAsync( + global::SeedNullableOptional.Core.BaseRequest request, + CancellationToken cancellationToken = default + ) + { + return SendRequestAsync(request, cancellationToken); + } + + internal async global::System.Threading.Tasks.Task SendRequestAsync( + global::SeedNullableOptional.Core.BaseRequest request, + CancellationToken cancellationToken = default + ) + { + // Apply the request timeout. + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var timeout = request.Options?.Timeout ?? Options.Timeout; + cts.CancelAfter(timeout); + + var httpRequest = await CreateHttpRequestAsync(request).ConfigureAwait(false); + // Send the request. + return await SendWithRetriesAsync(httpRequest, request.Options, cts.Token) + .ConfigureAwait(false); + } + + internal async global::System.Threading.Tasks.Task SendRequestAsync( + HttpRequestMessage request, + IRequestOptions? options, + CancellationToken cancellationToken = default + ) + { + // Apply the request timeout. + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var timeout = options?.Timeout ?? Options.Timeout; + cts.CancelAfter(timeout); + + // Send the request. + return await SendWithRetriesAsync(request, options, cts.Token).ConfigureAwait(false); + } + + private static async global::System.Threading.Tasks.Task CloneRequestAsync( + HttpRequestMessage request + ) + { + var clonedRequest = new HttpRequestMessage(request.Method, request.RequestUri); + clonedRequest.Version = request.Version; + switch (request.Content) + { + case MultipartContent oldMultipartFormContent: + var originalBoundary = + oldMultipartFormContent + .Headers.ContentType?.Parameters.First(p => + p.Name.Equals("boundary", StringComparison.OrdinalIgnoreCase) + ) + .Value?.Trim('"') ?? Guid.NewGuid().ToString(); + var newMultipartContent = oldMultipartFormContent switch + { + MultipartFormDataContent => new MultipartFormDataContent(originalBoundary), + _ => new MultipartContent(), + }; + foreach (var content in oldMultipartFormContent) + { + var ms = new MemoryStream(); + await content.CopyToAsync(ms).ConfigureAwait(false); + ms.Position = 0; + var newPart = new StreamContent(ms); + foreach (var header in oldMultipartFormContent.Headers) + { + newPart.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + + newMultipartContent.Add(newPart); + } + + clonedRequest.Content = newMultipartContent; + break; + default: + clonedRequest.Content = request.Content; + break; + } + + foreach (var header in request.Headers) + { + clonedRequest.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + + return clonedRequest; + } + + /// + /// Sends the request with retries, unless the request content is not retryable, + /// such as stream requests and multipart form data with stream content. + /// + private async global::System.Threading.Tasks.Task SendWithRetriesAsync( + HttpRequestMessage request, + IRequestOptions? options, + CancellationToken cancellationToken + ) + { + var httpClient = options?.HttpClient ?? Options.HttpClient; + var maxRetries = options?.MaxRetries ?? Options.MaxRetries; + var response = await httpClient + .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); + var isRetryableContent = IsRetryableContent(request); + + if (!isRetryableContent) + { + return new global::SeedNullableOptional.Core.ApiResponse + { + StatusCode = (int)response.StatusCode, + Raw = response, + }; + } + + for (var i = 0; i < maxRetries; i++) + { + if (!ShouldRetry(response)) + { + break; + } + + var delayMs = GetRetryDelayFromHeaders(response, i); + await SystemTask.Delay(delayMs, cancellationToken).ConfigureAwait(false); + using var retryRequest = await CloneRequestAsync(request).ConfigureAwait(false); + response = await httpClient + .SendAsync( + retryRequest, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken + ) + .ConfigureAwait(false); + } + + return new global::SeedNullableOptional.Core.ApiResponse + { + StatusCode = (int)response.StatusCode, + Raw = response, + }; + } + + private static bool ShouldRetry(HttpResponseMessage response) + { + var statusCode = (int)response.StatusCode; + return statusCode is 408 or 429 or >= 500; + } + + private static int AddPositiveJitter(int delayMs) + { +#if NET6_0_OR_GREATER + var random = Random.Shared.NextDouble(); +#else + double random; + lock (JitterLock) + { + random = JitterRandom.NextDouble(); + } +#endif + var jitterMultiplier = 1 + random * JitterFactor; + return (int)(delayMs * jitterMultiplier); + } + + private static int AddSymmetricJitter(int delayMs) + { +#if NET6_0_OR_GREATER + var random = Random.Shared.NextDouble(); +#else + double random; + lock (JitterLock) + { + random = JitterRandom.NextDouble(); + } +#endif + var jitterMultiplier = 1 + (random - 0.5) * JitterFactor; + return (int)(delayMs * jitterMultiplier); + } + + private int GetRetryDelayFromHeaders(HttpResponseMessage response, int retryAttempt) + { + if (response.Headers.TryGetValues("Retry-After", out var retryAfterValues)) + { + var retryAfter = retryAfterValues.FirstOrDefault(); + if (!string.IsNullOrEmpty(retryAfter)) + { + if (int.TryParse(retryAfter, out var retryAfterSeconds) && retryAfterSeconds > 0) + { + return Math.Min(retryAfterSeconds * 1000, MaxRetryDelayMs); + } + + if (DateTimeOffset.TryParse(retryAfter, out var retryAfterDate)) + { + var delay = (int)(retryAfterDate - DateTimeOffset.UtcNow).TotalMilliseconds; + if (delay > 0) + { + return Math.Min(delay, MaxRetryDelayMs); + } + } + } + } + + if (response.Headers.TryGetValues("X-RateLimit-Reset", out var rateLimitResetValues)) + { + var rateLimitReset = rateLimitResetValues.FirstOrDefault(); + if ( + !string.IsNullOrEmpty(rateLimitReset) + && long.TryParse(rateLimitReset, out var resetTime) + ) + { + var resetDateTime = DateTimeOffset.FromUnixTimeSeconds(resetTime); + var delay = (int)(resetDateTime - DateTimeOffset.UtcNow).TotalMilliseconds; + if (delay > 0) + { + return AddPositiveJitter(Math.Min(delay, MaxRetryDelayMs)); + } + } + } + + var exponentialDelay = Math.Min(BaseRetryDelay * (1 << retryAttempt), MaxRetryDelayMs); + return AddSymmetricJitter(exponentialDelay); + } + + private static bool IsRetryableContent(HttpRequestMessage request) + { + return request.Content switch + { + IIsRetryableContent c => c.IsRetryable, + StreamContent => false, + MultipartContent content => !content.Any(c => c is StreamContent), + _ => true, + }; + } + + internal async global::System.Threading.Tasks.Task CreateHttpRequestAsync( + global::SeedNullableOptional.Core.BaseRequest request + ) + { + var url = BuildUrl(request); + var httpRequest = new HttpRequestMessage(request.Method, url); + httpRequest.Content = request.CreateContent(); + var mergedHeaders = new Dictionary>(); + await MergeHeadersAsync(mergedHeaders, Options.Headers).ConfigureAwait(false); + MergeAdditionalHeaders(mergedHeaders, Options.AdditionalHeaders); + await MergeHeadersAsync(mergedHeaders, request.Headers).ConfigureAwait(false); + await MergeHeadersAsync(mergedHeaders, request.Options?.Headers).ConfigureAwait(false); + + MergeAdditionalHeaders(mergedHeaders, request.Options?.AdditionalHeaders ?? []); + SetHeaders(httpRequest, mergedHeaders); + return httpRequest; + } + + private static string BuildUrl(global::SeedNullableOptional.Core.BaseRequest request) + { + var baseUrl = request.Options?.BaseUrl ?? request.BaseUrl; + var trimmedBaseUrl = baseUrl.TrimEnd('/'); + var trimmedBasePath = request.Path.TrimStart('/'); + var url = $"{trimmedBaseUrl}/{trimmedBasePath}"; + + var queryParameters = GetQueryParameters(request); + if (!queryParameters.Any()) + return url; + + url += "?"; + url = queryParameters.Aggregate( + url, + (current, queryItem) => + { + if ( + queryItem.Value + is global::System.Collections.IEnumerable collection + and not string + ) + { + var items = collection + .Cast() + .Select(value => + $"{Uri.EscapeDataString(queryItem.Key)}={Uri.EscapeDataString(value?.ToString() ?? "")}" + ) + .ToList(); + if (items.Any()) + { + current += string.Join("&", items) + "&"; + } + } + else + { + current += + $"{Uri.EscapeDataString(queryItem.Key)}={Uri.EscapeDataString(queryItem.Value)}&"; + } + + return current; + } + ); + url = url[..^1]; + return url; + } + + private static List> GetQueryParameters( + global::SeedNullableOptional.Core.BaseRequest request + ) + { + var result = TransformToKeyValuePairs(request.Query); + if ( + request.Options?.AdditionalQueryParameters is null + || !request.Options.AdditionalQueryParameters.Any() + ) + { + return result; + } + + var additionalKeys = request + .Options.AdditionalQueryParameters.Select(p => p.Key) + .Distinct(); + foreach (var key in additionalKeys) + { + result.RemoveAll(kv => kv.Key == key); + } + + result.AddRange(request.Options.AdditionalQueryParameters); + return result; + } + + private static List> TransformToKeyValuePairs( + Dictionary inputDict + ) + { + var result = new List>(); + foreach (var kvp in inputDict) + { + switch (kvp.Value) + { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; + case string str: + result.Add(new KeyValuePair(kvp.Key, str)); + break; + case IEnumerable strList: + { + foreach (var value in strList) + { + result.Add(new KeyValuePair(kvp.Key, value)); + } + + break; + } + } + } + + return result; + } + + private static async SystemTask MergeHeadersAsync( + Dictionary> mergedHeaders, + Headers? headers + ) + { + if (headers is null) + { + return; + } + + foreach (var header in headers) + { + var value = await header.Value.ResolveAsync().ConfigureAwait(false); + if (value is not null) + { + mergedHeaders[header.Key] = [value]; + } + } + } + + private static void MergeAdditionalHeaders( + Dictionary> mergedHeaders, + IEnumerable>? headers + ) + { + if (headers is null) + { + return; + } + + var usedKeys = new HashSet(); + foreach (var header in headers) + { + if (header.Value is null) + { + mergedHeaders.Remove(header.Key); + usedKeys.Remove(header.Key); + continue; + } + + if (usedKeys.Contains(header.Key)) + { + mergedHeaders[header.Key].Add(header.Value); + } + else + { + mergedHeaders[header.Key] = [header.Value]; + usedKeys.Add(header.Key); + } + } + } + + private void SetHeaders( + HttpRequestMessage httpRequest, + Dictionary> mergedHeaders + ) + { + foreach (var kv in mergedHeaders) + { + foreach (var header in kv.Value) + { + if (header is null) + { + continue; + } + + httpRequest.Headers.TryAddWithoutValidation(kv.Key, header); + } + } + } + + private static (Encoding encoding, string? charset, string mediaType) ParseContentTypeOrDefault( + string? contentType, + Encoding encodingFallback, + string mediaTypeFallback + ) + { + var encoding = encodingFallback; + var mediaType = mediaTypeFallback; + string? charset = null; + if (string.IsNullOrEmpty(contentType)) + { + return (encoding, charset, mediaType); + } + + if (!MediaTypeHeaderValue.TryParse(contentType, out var mediaTypeHeaderValue)) + { + return (encoding, charset, mediaType); + } + + if (!string.IsNullOrEmpty(mediaTypeHeaderValue.CharSet)) + { + charset = mediaTypeHeaderValue.CharSet; + encoding = Encoding.GetEncoding(mediaTypeHeaderValue.CharSet); + } + + if (!string.IsNullOrEmpty(mediaTypeHeaderValue.MediaType)) + { + mediaType = mediaTypeHeaderValue.MediaType; + } + + return (encoding, charset, mediaType); + } + + /// + [Obsolete("Use global::SeedNullableOptional.Core.ApiResponse instead.")] + internal record ApiResponse : global::SeedNullableOptional.Core.ApiResponse; + + /// + [Obsolete("Use global::SeedNullableOptional.Core.BaseRequest instead.")] + internal abstract record BaseApiRequest : global::SeedNullableOptional.Core.BaseRequest; + + /// + [Obsolete("Use global::SeedNullableOptional.Core.EmptyRequest instead.")] + internal abstract record EmptyApiRequest : global::SeedNullableOptional.Core.EmptyRequest; + + /// + [Obsolete("Use global::SeedNullableOptional.Core.JsonRequest instead.")] + internal abstract record JsonApiRequest : global::SeedNullableOptional.Core.JsonRequest; + + /// + [Obsolete("Use global::SeedNullableOptional.Core.MultipartFormRequest instead.")] + internal abstract record MultipartFormRequest + : global::SeedNullableOptional.Core.MultipartFormRequest; + + /// + [Obsolete("Use global::SeedNullableOptional.Core.StreamRequest instead.")] + internal abstract record StreamApiRequest : global::SeedNullableOptional.Core.StreamRequest; +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/StreamRequest.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/StreamRequest.cs new file mode 100644 index 000000000000..38d196011114 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/StreamRequest.cs @@ -0,0 +1,29 @@ +using System.Net.Http; +using System.Net.Http.Headers; + +namespace SeedNullableOptional.Core; + +/// +/// The request object to be sent for streaming uploads. +/// +internal record StreamRequest : BaseRequest +{ + internal Stream? Body { get; init; } + + internal override HttpContent? CreateContent() + { + if (Body is null) + { + return null; + } + + var content = new StreamContent(Body) + { + Headers = + { + ContentType = MediaTypeHeaderValue.Parse(ContentType ?? "application/octet-stream"), + }, + }; + return content; + } +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/StringEnum.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/StringEnum.cs new file mode 100644 index 000000000000..8cb7c49174ed --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/StringEnum.cs @@ -0,0 +1,8 @@ +using System.Text.Json.Serialization; + +namespace SeedNullableOptional.Core; + +public interface IStringEnum : IEquatable +{ + public string Value { get; } +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/StringEnumExtensions.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/StringEnumExtensions.cs new file mode 100644 index 000000000000..43d93be7f20c --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/StringEnumExtensions.cs @@ -0,0 +1,6 @@ +namespace SeedNullableOptional.Core; + +internal static class StringEnumExtensions +{ + public static string Stringify(this IStringEnum stringEnum) => stringEnum.Value; +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/StringEnumSerializer.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/StringEnumSerializer.cs new file mode 100644 index 000000000000..528b0b4573ea --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/StringEnumSerializer.cs @@ -0,0 +1,25 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedNullableOptional.Core; + +internal class StringEnumSerializer : JsonConverter + where T : IStringEnum +{ + public override T? Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var stringValue = + reader.GetString() + ?? throw new global::System.Exception("The JSON value could not be read as a string."); + return (T?)Activator.CreateInstance(typeToConvert, stringValue); + } + + public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.Value); + } +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/ValueConvert.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/ValueConvert.cs new file mode 100644 index 000000000000..d85d7ef89964 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/Core/ValueConvert.cs @@ -0,0 +1,115 @@ +using global::System.Globalization; + +namespace SeedNullableOptional.Core; + +/// +/// Convert values to string for path and query parameters. +/// +public static class ValueConvert +{ + internal static string ToPathParameterString(T value) => ToString(value); + + internal static string ToPathParameterString(bool v) => ToString(v); + + internal static string ToPathParameterString(int v) => ToString(v); + + internal static string ToPathParameterString(long v) => ToString(v); + + internal static string ToPathParameterString(float v) => ToString(v); + + internal static string ToPathParameterString(double v) => ToString(v); + + internal static string ToPathParameterString(decimal v) => ToString(v); + + internal static string ToPathParameterString(short v) => ToString(v); + + internal static string ToPathParameterString(ushort v) => ToString(v); + + internal static string ToPathParameterString(uint v) => ToString(v); + + internal static string ToPathParameterString(ulong v) => ToString(v); + + internal static string ToPathParameterString(string v) => ToString(v); + + internal static string ToPathParameterString(char v) => ToString(v); + + internal static string ToPathParameterString(Guid v) => ToString(v); + + internal static string ToQueryStringValue(T value) => value is null ? "" : ToString(value); + + internal static string ToQueryStringValue(bool v) => ToString(v); + + internal static string ToQueryStringValue(int v) => ToString(v); + + internal static string ToQueryStringValue(long v) => ToString(v); + + internal static string ToQueryStringValue(float v) => ToString(v); + + internal static string ToQueryStringValue(double v) => ToString(v); + + internal static string ToQueryStringValue(decimal v) => ToString(v); + + internal static string ToQueryStringValue(short v) => ToString(v); + + internal static string ToQueryStringValue(ushort v) => ToString(v); + + internal static string ToQueryStringValue(uint v) => ToString(v); + + internal static string ToQueryStringValue(ulong v) => ToString(v); + + internal static string ToQueryStringValue(string v) => v is null ? "" : v; + + internal static string ToQueryStringValue(char v) => ToString(v); + + internal static string ToQueryStringValue(Guid v) => ToString(v); + + internal static string ToString(T value) + { + return value switch + { + null => "null", + string str => str, + true => "true", + false => "false", + int i => ToString(i), + long l => ToString(l), + float f => ToString(f), + double d => ToString(d), + decimal dec => ToString(dec), + short s => ToString(s), + ushort u => ToString(u), + uint u => ToString(u), + ulong u => ToString(u), + char c => ToString(c), + Guid guid => ToString(guid), + Enum e => JsonUtils.Serialize(e).Trim('"'), + _ => JsonUtils.Serialize(value).Trim('"'), + }; + } + + internal static string ToString(bool v) => v ? "true" : "false"; + + internal static string ToString(int v) => v.ToString(CultureInfo.InvariantCulture); + + internal static string ToString(long v) => v.ToString(CultureInfo.InvariantCulture); + + internal static string ToString(float v) => v.ToString(CultureInfo.InvariantCulture); + + internal static string ToString(double v) => v.ToString(CultureInfo.InvariantCulture); + + internal static string ToString(decimal v) => v.ToString(CultureInfo.InvariantCulture); + + internal static string ToString(short v) => v.ToString(CultureInfo.InvariantCulture); + + internal static string ToString(ushort v) => v.ToString(CultureInfo.InvariantCulture); + + internal static string ToString(uint v) => v.ToString(CultureInfo.InvariantCulture); + + internal static string ToString(ulong v) => v.ToString(CultureInfo.InvariantCulture); + + internal static string ToString(char v) => v.ToString(CultureInfo.InvariantCulture); + + internal static string ToString(string v) => v; + + internal static string ToString(Guid v) => v.ToString("D"); +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/ISeedNullableOptionalClient.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/ISeedNullableOptionalClient.cs new file mode 100644 index 000000000000..92233001e2f8 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/ISeedNullableOptionalClient.cs @@ -0,0 +1,6 @@ +namespace SeedNullableOptional; + +public partial interface ISeedNullableOptionalClient +{ + public NullableOptionalClient NullableOptional { get; } +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/INullableOptionalClient.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/INullableOptionalClient.cs new file mode 100644 index 000000000000..c86482e51d15 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/INullableOptionalClient.cs @@ -0,0 +1,124 @@ +namespace SeedNullableOptional; + +public partial interface INullableOptionalClient +{ + /// + /// Get a user by ID + /// + Task GetUserAsync( + string userId, + RequestOptions? options = null, + CancellationToken cancellationToken = default + ); + + /// + /// Create a new user + /// + Task CreateUserAsync( + CreateUserRequest request, + RequestOptions? options = null, + CancellationToken cancellationToken = default + ); + + /// + /// Update a user (partial update) + /// + Task UpdateUserAsync( + string userId, + UpdateUserRequest request, + RequestOptions? options = null, + CancellationToken cancellationToken = default + ); + + /// + /// List all users + /// + Task> ListUsersAsync( + ListUsersRequest request, + RequestOptions? options = null, + CancellationToken cancellationToken = default + ); + + /// + /// Search users + /// + Task> SearchUsersAsync( + SearchUsersRequest request, + RequestOptions? options = null, + CancellationToken cancellationToken = default + ); + + /// + /// Create a complex profile to test nullable enums and unions + /// + Task CreateComplexProfileAsync( + ComplexProfile request, + RequestOptions? options = null, + CancellationToken cancellationToken = default + ); + + /// + /// Get a complex profile by ID + /// + Task GetComplexProfileAsync( + string profileId, + RequestOptions? options = null, + CancellationToken cancellationToken = default + ); + + /// + /// Update complex profile to test nullable field updates + /// + Task UpdateComplexProfileAsync( + string profileId, + UpdateComplexProfileRequest request, + RequestOptions? options = null, + CancellationToken cancellationToken = default + ); + + /// + /// Test endpoint for validating null deserialization + /// + Task TestDeserializationAsync( + DeserializationTestRequest request, + RequestOptions? options = null, + CancellationToken cancellationToken = default + ); + + /// + /// Filter users by role with nullable enum + /// + Task> FilterByRoleAsync( + FilterByRoleRequest request, + RequestOptions? options = null, + CancellationToken cancellationToken = default + ); + + /// + /// Get notification settings which may be null + /// + Task GetNotificationSettingsAsync( + string userId, + RequestOptions? options = null, + CancellationToken cancellationToken = default + ); + + /// + /// Update tags to test array handling + /// + Task> UpdateTagsAsync( + string userId, + UpdateTagsRequest request, + RequestOptions? options = null, + CancellationToken cancellationToken = default + ); + + /// + /// Get search results with nullable unions + /// + Task?> GetSearchResultsAsync( + SearchRequest request, + RequestOptions? options = null, + CancellationToken cancellationToken = default + ); +} diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/NullableOptional/NullableOptionalClient.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/NullableOptionalClient.cs similarity index 99% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/NullableOptional/NullableOptionalClient.cs rename to seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/NullableOptionalClient.cs index 18e21b9b6fa2..4a7a9c199c56 100644 --- a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/NullableOptional/NullableOptionalClient.cs +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/NullableOptionalClient.cs @@ -442,7 +442,7 @@ public async Task> SearchUsersAsync( /// "nullableListOfNullables", /// "nullableListOfNullables", /// }, - /// NullableMapOfNullables = new Dictionary<string, Address?>() + /// NullableMapOfNullables = new Dictionary<string, Address>() /// { /// { /// "nullableMapOfNullables", @@ -805,7 +805,7 @@ public async Task> FilterByRoleAsync( var _query = new Dictionary(); if (request.Role != null) { - _query["role"] = request.Role.Value.ToString(); + _query["role"] = request.Role.ToString(); } if (request.Status != null) { @@ -969,7 +969,7 @@ public async Task> UpdateTagsAsync( /// new SearchRequest /// { /// Query = "query", - /// Filters = new Dictionary<string, string?>() { { "filters", "filters" } }, + /// Filters = new Dictionary<string, string>() { { "filters", "filters" } }, /// IncludeTypes = new List<string>() { "includeTypes", "includeTypes" }, /// } /// ); diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/NullableOptional/Requests/FilterByRoleRequest.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/Requests/FilterByRoleRequest.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/NullableOptional/Requests/FilterByRoleRequest.cs rename to seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/Requests/FilterByRoleRequest.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/NullableOptional/Requests/ListUsersRequest.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/Requests/ListUsersRequest.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/NullableOptional/Requests/ListUsersRequest.cs rename to seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/Requests/ListUsersRequest.cs diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/Requests/SearchRequest.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/Requests/SearchRequest.cs new file mode 100644 index 000000000000..5fbfadc6f159 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/Requests/SearchRequest.cs @@ -0,0 +1,23 @@ +using System.Text.Json.Serialization; +using SeedNullableOptional.Core; + +namespace SeedNullableOptional; + +[Serializable] +public record SearchRequest +{ + [JsonPropertyName("query")] + public required string Query { get; set; } + + [JsonPropertyName("filters")] + public Dictionary? Filters { get; set; } + + [JsonPropertyName("includeTypes")] + public IEnumerable? IncludeTypes { get; set; } + + /// + public override string ToString() + { + return JsonUtils.Serialize(this); + } +} diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/NullableOptional/Requests/SearchUsersRequest.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/Requests/SearchUsersRequest.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/NullableOptional/Requests/SearchUsersRequest.cs rename to seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/Requests/SearchUsersRequest.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/NullableOptional/Requests/UpdateComplexProfileRequest.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/Requests/UpdateComplexProfileRequest.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/NullableOptional/Requests/UpdateComplexProfileRequest.cs rename to seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/Requests/UpdateComplexProfileRequest.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/NullableOptional/Requests/UpdateTagsRequest.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/Requests/UpdateTagsRequest.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/NullableOptional/Requests/UpdateTagsRequest.cs rename to seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/Requests/UpdateTagsRequest.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/NullableOptional/Types/Address.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/Types/Address.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/NullableOptional/Types/Address.cs rename to seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/Types/Address.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/NullableOptional/Types/ComplexProfile.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/Types/ComplexProfile.cs similarity index 97% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/NullableOptional/Types/ComplexProfile.cs rename to seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/Types/ComplexProfile.cs index f8448b6dd2d6..a8387da7a736 100644 --- a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/NullableOptional/Types/ComplexProfile.cs +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/Types/ComplexProfile.cs @@ -63,7 +63,7 @@ public record ComplexProfile : IJsonOnDeserialized public IEnumerable? NullableListOfNullables { get; set; } [JsonPropertyName("nullableMapOfNullables")] - public Dictionary? NullableMapOfNullables { get; set; } + public Dictionary? NullableMapOfNullables { get; set; } [JsonPropertyName("nullableListOfUnions")] public IEnumerable? NullableListOfUnions { get; set; } diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/NullableOptional/Types/CreateUserRequest.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/Types/CreateUserRequest.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/NullableOptional/Types/CreateUserRequest.cs rename to seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/Types/CreateUserRequest.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/NullableOptional/Types/DeserializationTestRequest.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/Types/DeserializationTestRequest.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/NullableOptional/Types/DeserializationTestRequest.cs rename to seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/Types/DeserializationTestRequest.cs diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/Types/DeserializationTestResponse.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/Types/DeserializationTestResponse.cs new file mode 100644 index 000000000000..25d6bc8b4747 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/Types/DeserializationTestResponse.cs @@ -0,0 +1,40 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using SeedNullableOptional.Core; + +namespace SeedNullableOptional; + +/// +/// Response for deserialization test +/// +[Serializable] +public record DeserializationTestResponse : IJsonOnDeserialized +{ + [JsonExtensionData] + private readonly IDictionary _extensionData = + new Dictionary(); + + [JsonPropertyName("echo")] + public required DeserializationTestRequest Echo { get; set; } + + [JsonPropertyName("processedAt")] + public required DateTime ProcessedAt { get; set; } + + [JsonPropertyName("nullCount")] + public required int NullCount { get; set; } + + [JsonPropertyName("presentFieldsCount")] + public required int PresentFieldsCount { get; set; } + + [JsonIgnore] + public ReadOnlyAdditionalProperties AdditionalProperties { get; private set; } = new(); + + void IJsonOnDeserialized.OnDeserialized() => + AdditionalProperties.CopyFromExtensionData(_extensionData); + + /// + public override string ToString() + { + return JsonUtils.Serialize(this); + } +} diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/NullableOptional/Types/Document.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/Types/Document.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/NullableOptional/Types/Document.cs rename to seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/Types/Document.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/NullableOptional/Types/EmailNotification.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/Types/EmailNotification.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/NullableOptional/Types/EmailNotification.cs rename to seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/Types/EmailNotification.cs diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/Types/NotificationMethod.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/Types/NotificationMethod.cs new file mode 100644 index 000000000000..75a99aa62494 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/Types/NotificationMethod.cs @@ -0,0 +1,325 @@ +// ReSharper disable NullableWarningSuppressionIsUsed +// ReSharper disable InconsistentNaming + +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using SeedNullableOptional.Core; + +namespace SeedNullableOptional; + +/// +/// Discriminated union for testing nullable unions +/// +[JsonConverter(typeof(NotificationMethod.JsonConverter))] +[Serializable] +public record NotificationMethod +{ + internal NotificationMethod(string type, object? value) + { + Type = type; + Value = value; + } + + /// + /// Create an instance of NotificationMethod with . + /// + public NotificationMethod(NotificationMethod.Email value) + { + Type = "email"; + Value = value.Value; + } + + /// + /// Create an instance of NotificationMethod with . + /// + public NotificationMethod(NotificationMethod.Sms value) + { + Type = "sms"; + Value = value.Value; + } + + /// + /// Create an instance of NotificationMethod with . + /// + public NotificationMethod(NotificationMethod.Push value) + { + Type = "push"; + Value = value.Value; + } + + /// + /// Discriminant value + /// + [JsonPropertyName("type")] + public string Type { get; internal set; } + + /// + /// Discriminated union value + /// + public object? Value { get; internal set; } + + /// + /// Returns true if is "email" + /// + public bool IsEmail => Type == "email"; + + /// + /// Returns true if is "sms" + /// + public bool IsSms => Type == "sms"; + + /// + /// Returns true if is "push" + /// + public bool IsPush => Type == "push"; + + /// + /// Returns the value as a if is 'email', otherwise throws an exception. + /// + /// Thrown when is not 'email'. + public SeedNullableOptional.EmailNotification AsEmail() => + IsEmail + ? (SeedNullableOptional.EmailNotification)Value! + : throw new System.Exception("NotificationMethod.Type is not 'email'"); + + /// + /// Returns the value as a if is 'sms', otherwise throws an exception. + /// + /// Thrown when is not 'sms'. + public SeedNullableOptional.SmsNotification AsSms() => + IsSms + ? (SeedNullableOptional.SmsNotification)Value! + : throw new System.Exception("NotificationMethod.Type is not 'sms'"); + + /// + /// Returns the value as a if is 'push', otherwise throws an exception. + /// + /// Thrown when is not 'push'. + public SeedNullableOptional.PushNotification AsPush() => + IsPush + ? (SeedNullableOptional.PushNotification)Value! + : throw new System.Exception("NotificationMethod.Type is not 'push'"); + + public T Match( + Func onEmail, + Func onSms, + Func onPush, + Func onUnknown_ + ) + { + return Type switch + { + "email" => onEmail(AsEmail()), + "sms" => onSms(AsSms()), + "push" => onPush(AsPush()), + _ => onUnknown_(Type, Value), + }; + } + + public void Visit( + Action onEmail, + Action onSms, + Action onPush, + Action onUnknown_ + ) + { + switch (Type) + { + case "email": + onEmail(AsEmail()); + break; + case "sms": + onSms(AsSms()); + break; + case "push": + onPush(AsPush()); + break; + default: + onUnknown_(Type, Value); + break; + } + } + + /// + /// Attempts to cast the value to a and returns true if successful. + /// + public bool TryAsEmail(out SeedNullableOptional.EmailNotification? value) + { + if (Type == "email") + { + value = (SeedNullableOptional.EmailNotification)Value!; + return true; + } + value = null; + return false; + } + + /// + /// Attempts to cast the value to a and returns true if successful. + /// + public bool TryAsSms(out SeedNullableOptional.SmsNotification? value) + { + if (Type == "sms") + { + value = (SeedNullableOptional.SmsNotification)Value!; + return true; + } + value = null; + return false; + } + + /// + /// Attempts to cast the value to a and returns true if successful. + /// + public bool TryAsPush(out SeedNullableOptional.PushNotification? value) + { + if (Type == "push") + { + value = (SeedNullableOptional.PushNotification)Value!; + return true; + } + value = null; + return false; + } + + public override string ToString() => JsonUtils.Serialize(this); + + public static implicit operator NotificationMethod(NotificationMethod.Email value) => + new(value); + + public static implicit operator NotificationMethod(NotificationMethod.Sms value) => new(value); + + public static implicit operator NotificationMethod(NotificationMethod.Push value) => new(value); + + [Serializable] + internal sealed class JsonConverter : JsonConverter + { + public override bool CanConvert(System.Type typeToConvert) => + typeof(NotificationMethod).IsAssignableFrom(typeToConvert); + + public override NotificationMethod Read( + ref Utf8JsonReader reader, + System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var json = JsonElement.ParseValue(ref reader); + if (!json.TryGetProperty("type", out var discriminatorElement)) + { + throw new JsonException("Missing discriminator property 'type'"); + } + if (discriminatorElement.ValueKind != JsonValueKind.String) + { + if (discriminatorElement.ValueKind == JsonValueKind.Null) + { + throw new JsonException("Discriminator property 'type' is null"); + } + + throw new JsonException( + $"Discriminator property 'type' is not a string, instead is {discriminatorElement.ToString()}" + ); + } + + var discriminator = + discriminatorElement.GetString() + ?? throw new JsonException("Discriminator property 'type' is null"); + + var value = discriminator switch + { + "email" => json.Deserialize(options) + ?? throw new JsonException( + "Failed to deserialize SeedNullableOptional.EmailNotification" + ), + "sms" => json.Deserialize(options) + ?? throw new JsonException( + "Failed to deserialize SeedNullableOptional.SmsNotification" + ), + "push" => json.Deserialize(options) + ?? throw new JsonException( + "Failed to deserialize SeedNullableOptional.PushNotification" + ), + _ => json.Deserialize(options), + }; + return new NotificationMethod(discriminator, value); + } + + public override void Write( + Utf8JsonWriter writer, + NotificationMethod value, + JsonSerializerOptions options + ) + { + JsonNode json = + value.Type switch + { + "email" => JsonSerializer.SerializeToNode(value.Value, options), + "sms" => JsonSerializer.SerializeToNode(value.Value, options), + "push" => JsonSerializer.SerializeToNode(value.Value, options), + _ => JsonSerializer.SerializeToNode(value.Value, options), + } ?? new JsonObject(); + json["type"] = value.Type; + json.WriteTo(writer, options); + } + } + + /// + /// Discriminated union type for email + /// + [Serializable] + public struct Email + { + public Email(SeedNullableOptional.EmailNotification value) + { + Value = value; + } + + internal SeedNullableOptional.EmailNotification Value { get; set; } + + public override string ToString() => Value.ToString() ?? "null"; + + public static implicit operator NotificationMethod.Email( + SeedNullableOptional.EmailNotification value + ) => new(value); + } + + /// + /// Discriminated union type for sms + /// + [Serializable] + public struct Sms + { + public Sms(SeedNullableOptional.SmsNotification value) + { + Value = value; + } + + internal SeedNullableOptional.SmsNotification Value { get; set; } + + public override string ToString() => Value.ToString() ?? "null"; + + public static implicit operator NotificationMethod.Sms( + SeedNullableOptional.SmsNotification value + ) => new(value); + } + + /// + /// Discriminated union type for push + /// + [Serializable] + public struct Push + { + public Push(SeedNullableOptional.PushNotification value) + { + Value = value; + } + + internal SeedNullableOptional.PushNotification Value { get; set; } + + public override string ToString() => Value.ToString() ?? "null"; + + public static implicit operator NotificationMethod.Push( + SeedNullableOptional.PushNotification value + ) => new(value); + } +} diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/NullableOptional/Types/Organization.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/Types/Organization.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/NullableOptional/Types/Organization.cs rename to seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/Types/Organization.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/NullableOptional/Types/PushNotification.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/Types/PushNotification.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/NullableOptional/Types/PushNotification.cs rename to seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/Types/PushNotification.cs diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/Types/SearchResult.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/Types/SearchResult.cs new file mode 100644 index 000000000000..d2dd68fb6532 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/Types/SearchResult.cs @@ -0,0 +1,324 @@ +// ReSharper disable NullableWarningSuppressionIsUsed +// ReSharper disable InconsistentNaming + +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using SeedNullableOptional.Core; + +namespace SeedNullableOptional; + +/// +/// Undiscriminated union for testing +/// +[JsonConverter(typeof(SearchResult.JsonConverter))] +[Serializable] +public record SearchResult +{ + internal SearchResult(string type, object? value) + { + Type = type; + Value = value; + } + + /// + /// Create an instance of SearchResult with . + /// + public SearchResult(SearchResult.User value) + { + Type = "user"; + Value = value.Value; + } + + /// + /// Create an instance of SearchResult with . + /// + public SearchResult(SearchResult.Organization value) + { + Type = "organization"; + Value = value.Value; + } + + /// + /// Create an instance of SearchResult with . + /// + public SearchResult(SearchResult.Document value) + { + Type = "document"; + Value = value.Value; + } + + /// + /// Discriminant value + /// + [JsonPropertyName("type")] + public string Type { get; internal set; } + + /// + /// Discriminated union value + /// + public object? Value { get; internal set; } + + /// + /// Returns true if is "user" + /// + public bool IsUser => Type == "user"; + + /// + /// Returns true if is "organization" + /// + public bool IsOrganization => Type == "organization"; + + /// + /// Returns true if is "document" + /// + public bool IsDocument => Type == "document"; + + /// + /// Returns the value as a if is 'user', otherwise throws an exception. + /// + /// Thrown when is not 'user'. + public SeedNullableOptional.UserResponse AsUser() => + IsUser + ? (SeedNullableOptional.UserResponse)Value! + : throw new System.Exception("SearchResult.Type is not 'user'"); + + /// + /// Returns the value as a if is 'organization', otherwise throws an exception. + /// + /// Thrown when is not 'organization'. + public SeedNullableOptional.Organization AsOrganization() => + IsOrganization + ? (SeedNullableOptional.Organization)Value! + : throw new System.Exception("SearchResult.Type is not 'organization'"); + + /// + /// Returns the value as a if is 'document', otherwise throws an exception. + /// + /// Thrown when is not 'document'. + public SeedNullableOptional.Document AsDocument() => + IsDocument + ? (SeedNullableOptional.Document)Value! + : throw new System.Exception("SearchResult.Type is not 'document'"); + + public T Match( + Func onUser, + Func onOrganization, + Func onDocument, + Func onUnknown_ + ) + { + return Type switch + { + "user" => onUser(AsUser()), + "organization" => onOrganization(AsOrganization()), + "document" => onDocument(AsDocument()), + _ => onUnknown_(Type, Value), + }; + } + + public void Visit( + Action onUser, + Action onOrganization, + Action onDocument, + Action onUnknown_ + ) + { + switch (Type) + { + case "user": + onUser(AsUser()); + break; + case "organization": + onOrganization(AsOrganization()); + break; + case "document": + onDocument(AsDocument()); + break; + default: + onUnknown_(Type, Value); + break; + } + } + + /// + /// Attempts to cast the value to a and returns true if successful. + /// + public bool TryAsUser(out SeedNullableOptional.UserResponse? value) + { + if (Type == "user") + { + value = (SeedNullableOptional.UserResponse)Value!; + return true; + } + value = null; + return false; + } + + /// + /// Attempts to cast the value to a and returns true if successful. + /// + public bool TryAsOrganization(out SeedNullableOptional.Organization? value) + { + if (Type == "organization") + { + value = (SeedNullableOptional.Organization)Value!; + return true; + } + value = null; + return false; + } + + /// + /// Attempts to cast the value to a and returns true if successful. + /// + public bool TryAsDocument(out SeedNullableOptional.Document? value) + { + if (Type == "document") + { + value = (SeedNullableOptional.Document)Value!; + return true; + } + value = null; + return false; + } + + public override string ToString() => JsonUtils.Serialize(this); + + public static implicit operator SearchResult(SearchResult.User value) => new(value); + + public static implicit operator SearchResult(SearchResult.Organization value) => new(value); + + public static implicit operator SearchResult(SearchResult.Document value) => new(value); + + [Serializable] + internal sealed class JsonConverter : JsonConverter + { + public override bool CanConvert(System.Type typeToConvert) => + typeof(SearchResult).IsAssignableFrom(typeToConvert); + + public override SearchResult Read( + ref Utf8JsonReader reader, + System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var json = JsonElement.ParseValue(ref reader); + if (!json.TryGetProperty("type", out var discriminatorElement)) + { + throw new JsonException("Missing discriminator property 'type'"); + } + if (discriminatorElement.ValueKind != JsonValueKind.String) + { + if (discriminatorElement.ValueKind == JsonValueKind.Null) + { + throw new JsonException("Discriminator property 'type' is null"); + } + + throw new JsonException( + $"Discriminator property 'type' is not a string, instead is {discriminatorElement.ToString()}" + ); + } + + var discriminator = + discriminatorElement.GetString() + ?? throw new JsonException("Discriminator property 'type' is null"); + + var value = discriminator switch + { + "user" => json.Deserialize(options) + ?? throw new JsonException( + "Failed to deserialize SeedNullableOptional.UserResponse" + ), + "organization" => json.Deserialize(options) + ?? throw new JsonException( + "Failed to deserialize SeedNullableOptional.Organization" + ), + "document" => json.Deserialize(options) + ?? throw new JsonException( + "Failed to deserialize SeedNullableOptional.Document" + ), + _ => json.Deserialize(options), + }; + return new SearchResult(discriminator, value); + } + + public override void Write( + Utf8JsonWriter writer, + SearchResult value, + JsonSerializerOptions options + ) + { + JsonNode json = + value.Type switch + { + "user" => JsonSerializer.SerializeToNode(value.Value, options), + "organization" => JsonSerializer.SerializeToNode(value.Value, options), + "document" => JsonSerializer.SerializeToNode(value.Value, options), + _ => JsonSerializer.SerializeToNode(value.Value, options), + } ?? new JsonObject(); + json["type"] = value.Type; + json.WriteTo(writer, options); + } + } + + /// + /// Discriminated union type for user + /// + [Serializable] + public struct User + { + public User(SeedNullableOptional.UserResponse value) + { + Value = value; + } + + internal SeedNullableOptional.UserResponse Value { get; set; } + + public override string ToString() => Value.ToString() ?? "null"; + + public static implicit operator SearchResult.User( + SeedNullableOptional.UserResponse value + ) => new(value); + } + + /// + /// Discriminated union type for organization + /// + [Serializable] + public struct Organization + { + public Organization(SeedNullableOptional.Organization value) + { + Value = value; + } + + internal SeedNullableOptional.Organization Value { get; set; } + + public override string ToString() => Value.ToString() ?? "null"; + + public static implicit operator SearchResult.Organization( + SeedNullableOptional.Organization value + ) => new(value); + } + + /// + /// Discriminated union type for document + /// + [Serializable] + public struct Document + { + public Document(SeedNullableOptional.Document value) + { + Value = value; + } + + internal SeedNullableOptional.Document Value { get; set; } + + public override string ToString() => Value.ToString() ?? "null"; + + public static implicit operator SearchResult.Document( + SeedNullableOptional.Document value + ) => new(value); + } +} diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/NullableOptional/Types/SmsNotification.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/Types/SmsNotification.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/NullableOptional/Types/SmsNotification.cs rename to seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/Types/SmsNotification.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/NullableOptional/Types/UpdateUserRequest.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/Types/UpdateUserRequest.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/NullableOptional/Types/UpdateUserRequest.cs rename to seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/Types/UpdateUserRequest.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/NullableOptional/Types/UserProfile.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/Types/UserProfile.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/NullableOptional/Types/UserProfile.cs rename to seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/Types/UserProfile.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/NullableOptional/Types/UserResponse.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/Types/UserResponse.cs similarity index 100% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/NullableOptional/Types/UserResponse.cs rename to seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/Types/UserResponse.cs diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/Types/UserRole.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/Types/UserRole.cs new file mode 100644 index 000000000000..b27b033ad94c --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/Types/UserRole.cs @@ -0,0 +1,71 @@ +using System.Text.Json.Serialization; +using SeedNullableOptional.Core; + +namespace SeedNullableOptional; + +[JsonConverter(typeof(StringEnumSerializer))] +[Serializable] +public readonly record struct UserRole : IStringEnum +{ + public static readonly UserRole Admin = new(Values.Admin); + + public static readonly UserRole User = new(Values.User); + + public static readonly UserRole Guest = new(Values.Guest); + + public static readonly UserRole Moderator = new(Values.Moderator); + + public UserRole(string value) + { + Value = value; + } + + /// + /// The string value of the enum. + /// + public string Value { get; } + + /// + /// Create a string enum with the given value. + /// + public static UserRole FromCustom(string value) + { + return new UserRole(value); + } + + public bool Equals(string? other) + { + return Value.Equals(other); + } + + /// + /// Returns the string value of the enum. + /// + public override string ToString() + { + return Value; + } + + public static bool operator ==(UserRole value1, string value2) => value1.Value.Equals(value2); + + public static bool operator !=(UserRole value1, string value2) => !value1.Value.Equals(value2); + + public static explicit operator string(UserRole value) => value.Value; + + public static explicit operator UserRole(string value) => new(value); + + /// + /// Constant strings for enum values + /// + [Serializable] + public static class Values + { + public const string Admin = "ADMIN"; + + public const string User = "USER"; + + public const string Guest = "GUEST"; + + public const string Moderator = "MODERATOR"; + } +} diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/Types/UserStatus.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/Types/UserStatus.cs new file mode 100644 index 000000000000..08816dfbe443 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/NullableOptional/Types/UserStatus.cs @@ -0,0 +1,72 @@ +using System.Text.Json.Serialization; +using SeedNullableOptional.Core; + +namespace SeedNullableOptional; + +[JsonConverter(typeof(StringEnumSerializer))] +[Serializable] +public readonly record struct UserStatus : IStringEnum +{ + public static readonly UserStatus Active = new(Values.Active); + + public static readonly UserStatus Inactive = new(Values.Inactive); + + public static readonly UserStatus Suspended = new(Values.Suspended); + + public static readonly UserStatus Deleted = new(Values.Deleted); + + public UserStatus(string value) + { + Value = value; + } + + /// + /// The string value of the enum. + /// + public string Value { get; } + + /// + /// Create a string enum with the given value. + /// + public static UserStatus FromCustom(string value) + { + return new UserStatus(value); + } + + public bool Equals(string? other) + { + return Value.Equals(other); + } + + /// + /// Returns the string value of the enum. + /// + public override string ToString() + { + return Value; + } + + public static bool operator ==(UserStatus value1, string value2) => value1.Value.Equals(value2); + + public static bool operator !=(UserStatus value1, string value2) => + !value1.Value.Equals(value2); + + public static explicit operator string(UserStatus value) => value.Value; + + public static explicit operator UserStatus(string value) => new(value); + + /// + /// Constant strings for enum values + /// + [Serializable] + public static class Values + { + public const string Active = "active"; + + public const string Inactive = "inactive"; + + public const string Suspended = "suspended"; + + public const string Deleted = "deleted"; + } +} diff --git a/seed/csharp-sdk/nullable/src/SeedNullable/SeedNullable.Custom.props b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/SeedNullableOptional.Custom.props similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable/SeedNullable.Custom.props rename to seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/SeedNullableOptional.Custom.props diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/SeedNullableOptional.csproj b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/SeedNullableOptional.csproj new file mode 100644 index 000000000000..d157312ffb62 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/SeedNullableOptional.csproj @@ -0,0 +1,61 @@ + + + net462;net8.0;net7.0;net6.0;netstandard2.0 + enable + 12 + enable + 0.0.1 + $(Version) + $(Version) + README.md + https://github.com/nullable-optional/fern + true + + + + false + + + $(DefineConstants);USE_PORTABLE_DATE_ONLY + true + + + + + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + <_Parameter1>SeedNullableOptional.Test + + + + + diff --git a/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/SeedNullableOptionalClient.cs b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/SeedNullableOptionalClient.cs new file mode 100644 index 000000000000..8fe460413df3 --- /dev/null +++ b/seed/csharp-sdk/nullable-optional/no-custom-config/src/SeedNullableOptional/SeedNullableOptionalClient.cs @@ -0,0 +1,33 @@ +using SeedNullableOptional.Core; + +namespace SeedNullableOptional; + +public partial class SeedNullableOptionalClient : ISeedNullableOptionalClient +{ + private readonly RawClient _client; + + public SeedNullableOptionalClient(ClientOptions? clientOptions = null) + { + var defaultHeaders = new Headers( + new Dictionary() + { + { "X-Fern-Language", "C#" }, + { "X-Fern-SDK-Name", "SeedNullableOptional" }, + { "X-Fern-SDK-Version", Version.Current }, + { "User-Agent", "Fernnullable-optional/0.0.1" }, + } + ); + clientOptions ??= new ClientOptions(); + foreach (var header in defaultHeaders) + { + if (!clientOptions.Headers.ContainsKey(header.Key)) + { + clientOptions.Headers[header.Key] = header.Value; + } + } + _client = new RawClient(clientOptions); + NullableOptional = new NullableOptionalClient(_client); + } + + public NullableOptionalClient NullableOptional { get; } +} diff --git a/seed/csharp-sdk/nullable-optional/reference.md b/seed/csharp-sdk/nullable-optional/reference.md index 4d18b5e648c2..349d70933244 100644 --- a/seed/csharp-sdk/nullable-optional/reference.md +++ b/seed/csharp-sdk/nullable-optional/reference.md @@ -449,7 +449,7 @@ await client.NullableOptional.CreateComplexProfileAsync( "optionalNullableArray", "optionalNullableArray", }, - NullableListOfNullables = new List() + NullableListOfNullables = new List() { "nullableListOfNullables", "nullableListOfNullables", diff --git a/seed/csharp-sdk/nullable-optional/snippet.json b/seed/csharp-sdk/nullable-optional/snippet.json index 155fcfc46df7..b9ab7cd0c7e2 100644 --- a/seed/csharp-sdk/nullable-optional/snippet.json +++ b/seed/csharp-sdk/nullable-optional/snippet.json @@ -70,7 +70,7 @@ }, "snippet": { "type": "csharp", - "client": "using SeedNullableOptional;\n\nvar client = new SeedNullableOptionalClient();\nawait client.NullableOptional.CreateComplexProfileAsync(\n new ComplexProfile\n {\n Id = \"id\",\n NullableRole = UserRole.Admin,\n OptionalRole = UserRole.Admin,\n OptionalNullableRole = UserRole.Admin,\n NullableStatus = UserStatus.Active,\n OptionalStatus = UserStatus.Active,\n OptionalNullableStatus = UserStatus.Active,\n NullableNotification = new NotificationMethod(\n new NotificationMethod.Email(\n new EmailNotification\n {\n EmailAddress = \"emailAddress\",\n Subject = \"subject\",\n HtmlContent = \"htmlContent\",\n }\n )\n ),\n OptionalNotification = new NotificationMethod(\n new NotificationMethod.Email(\n new EmailNotification\n {\n EmailAddress = \"emailAddress\",\n Subject = \"subject\",\n HtmlContent = \"htmlContent\",\n }\n )\n ),\n OptionalNullableNotification = new NotificationMethod(\n new NotificationMethod.Email(\n new EmailNotification\n {\n EmailAddress = \"emailAddress\",\n Subject = \"subject\",\n HtmlContent = \"htmlContent\",\n }\n )\n ),\n NullableSearchResult = new SearchResult(\n new SearchResult.User(\n new UserResponse\n {\n Id = \"id\",\n Username = \"username\",\n Email = \"email\",\n Phone = \"phone\",\n CreatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000),\n UpdatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000),\n Address = new Address\n {\n Street = \"street\",\n City = \"city\",\n State = \"state\",\n ZipCode = \"zipCode\",\n Country = \"country\",\n BuildingId = \"buildingId\",\n TenantId = \"tenantId\",\n },\n }\n )\n ),\n OptionalSearchResult = new SearchResult(\n new SearchResult.User(\n new UserResponse\n {\n Id = \"id\",\n Username = \"username\",\n Email = \"email\",\n Phone = \"phone\",\n CreatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000),\n UpdatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000),\n Address = new Address\n {\n Street = \"street\",\n City = \"city\",\n State = \"state\",\n ZipCode = \"zipCode\",\n Country = \"country\",\n BuildingId = \"buildingId\",\n TenantId = \"tenantId\",\n },\n }\n )\n ),\n NullableArray = new List() { \"nullableArray\", \"nullableArray\" },\n OptionalArray = new List() { \"optionalArray\", \"optionalArray\" },\n OptionalNullableArray = new List()\n {\n \"optionalNullableArray\",\n \"optionalNullableArray\",\n },\n NullableListOfNullables = new List()\n {\n \"nullableListOfNullables\",\n \"nullableListOfNullables\",\n },\n NullableMapOfNullables = new Dictionary()\n {\n {\n \"nullableMapOfNullables\",\n new Address\n {\n Street = \"street\",\n City = \"city\",\n State = \"state\",\n ZipCode = \"zipCode\",\n Country = \"country\",\n BuildingId = \"buildingId\",\n TenantId = \"tenantId\",\n }\n },\n },\n NullableListOfUnions = new List()\n {\n new NotificationMethod(\n new NotificationMethod.Email(\n new EmailNotification\n {\n EmailAddress = \"emailAddress\",\n Subject = \"subject\",\n HtmlContent = \"htmlContent\",\n }\n )\n ),\n new NotificationMethod(\n new NotificationMethod.Email(\n new EmailNotification\n {\n EmailAddress = \"emailAddress\",\n Subject = \"subject\",\n HtmlContent = \"htmlContent\",\n }\n )\n ),\n },\n OptionalMapOfEnums = new Dictionary()\n {\n { \"optionalMapOfEnums\", UserRole.Admin },\n },\n }\n);\n" + "client": "using SeedNullableOptional;\n\nvar client = new SeedNullableOptionalClient();\nawait client.NullableOptional.CreateComplexProfileAsync(\n new ComplexProfile\n {\n Id = \"id\",\n NullableRole = UserRole.Admin,\n OptionalRole = UserRole.Admin,\n OptionalNullableRole = UserRole.Admin,\n NullableStatus = UserStatus.Active,\n OptionalStatus = UserStatus.Active,\n OptionalNullableStatus = UserStatus.Active,\n NullableNotification = new NotificationMethod(\n new NotificationMethod.Email(\n new EmailNotification\n {\n EmailAddress = \"emailAddress\",\n Subject = \"subject\",\n HtmlContent = \"htmlContent\",\n }\n )\n ),\n OptionalNotification = new NotificationMethod(\n new NotificationMethod.Email(\n new EmailNotification\n {\n EmailAddress = \"emailAddress\",\n Subject = \"subject\",\n HtmlContent = \"htmlContent\",\n }\n )\n ),\n OptionalNullableNotification = new NotificationMethod(\n new NotificationMethod.Email(\n new EmailNotification\n {\n EmailAddress = \"emailAddress\",\n Subject = \"subject\",\n HtmlContent = \"htmlContent\",\n }\n )\n ),\n NullableSearchResult = new SearchResult(\n new SearchResult.User(\n new UserResponse\n {\n Id = \"id\",\n Username = \"username\",\n Email = \"email\",\n Phone = \"phone\",\n CreatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000),\n UpdatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000),\n Address = new Address\n {\n Street = \"street\",\n City = \"city\",\n State = \"state\",\n ZipCode = \"zipCode\",\n Country = \"country\",\n BuildingId = \"buildingId\",\n TenantId = \"tenantId\",\n },\n }\n )\n ),\n OptionalSearchResult = new SearchResult(\n new SearchResult.User(\n new UserResponse\n {\n Id = \"id\",\n Username = \"username\",\n Email = \"email\",\n Phone = \"phone\",\n CreatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000),\n UpdatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000),\n Address = new Address\n {\n Street = \"street\",\n City = \"city\",\n State = \"state\",\n ZipCode = \"zipCode\",\n Country = \"country\",\n BuildingId = \"buildingId\",\n TenantId = \"tenantId\",\n },\n }\n )\n ),\n NullableArray = new List() { \"nullableArray\", \"nullableArray\" },\n OptionalArray = new List() { \"optionalArray\", \"optionalArray\" },\n OptionalNullableArray = new List()\n {\n \"optionalNullableArray\",\n \"optionalNullableArray\",\n },\n NullableListOfNullables = new List()\n {\n \"nullableListOfNullables\",\n \"nullableListOfNullables\",\n },\n NullableMapOfNullables = new Dictionary()\n {\n {\n \"nullableMapOfNullables\",\n new Address\n {\n Street = \"street\",\n City = \"city\",\n State = \"state\",\n ZipCode = \"zipCode\",\n Country = \"country\",\n BuildingId = \"buildingId\",\n TenantId = \"tenantId\",\n }\n },\n },\n NullableListOfUnions = new List()\n {\n new NotificationMethod(\n new NotificationMethod.Email(\n new EmailNotification\n {\n EmailAddress = \"emailAddress\",\n Subject = \"subject\",\n HtmlContent = \"htmlContent\",\n }\n )\n ),\n new NotificationMethod(\n new NotificationMethod.Email(\n new EmailNotification\n {\n EmailAddress = \"emailAddress\",\n Subject = \"subject\",\n HtmlContent = \"htmlContent\",\n }\n )\n ),\n },\n OptionalMapOfEnums = new Dictionary()\n {\n { \"optionalMapOfEnums\", UserRole.Admin },\n },\n }\n);\n" } }, { diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/JsonConfiguration.cs b/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/JsonConfiguration.cs deleted file mode 100644 index 551cd4c71aa2..000000000000 --- a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional/Core/JsonConfiguration.cs +++ /dev/null @@ -1,180 +0,0 @@ -using global::System.Reflection; -using global::System.Text.Json; -using global::System.Text.Json.Nodes; -using global::System.Text.Json.Serialization; -using global::System.Text.Json.Serialization.Metadata; - -namespace SeedNullableOptional.Core; - -internal static partial class JsonOptions -{ - internal static readonly JsonSerializerOptions JsonSerializerOptions; - - static JsonOptions() - { - var options = new JsonSerializerOptions - { - Converters = { new DateTimeSerializer(), -#if USE_PORTABLE_DATE_ONLY - new DateOnlyConverter(), -#endif - new OneOfSerializer() }, -#if DEBUG - WriteIndented = true, -#endif - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - TypeInfoResolver = new DefaultJsonTypeInfoResolver - { - Modifiers = - { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, - }, - }, - }; - ConfigureJsonSerializerOptions(options); - JsonSerializerOptions = options; - } - - static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); -} - -internal static class JsonUtils -{ - internal static string Serialize(T obj) => - JsonSerializer.Serialize(obj, JsonOptions.JsonSerializerOptions); - - internal static JsonElement SerializeToElement(T obj) => - JsonSerializer.SerializeToElement(obj, JsonOptions.JsonSerializerOptions); - - internal static JsonDocument SerializeToDocument(T obj) => - JsonSerializer.SerializeToDocument(obj, JsonOptions.JsonSerializerOptions); - - internal static JsonNode? SerializeToNode(T obj) => - JsonSerializer.SerializeToNode(obj, JsonOptions.JsonSerializerOptions); - - internal static byte[] SerializeToUtf8Bytes(T obj) => - JsonSerializer.SerializeToUtf8Bytes(obj, JsonOptions.JsonSerializerOptions); - - internal static string SerializeWithAdditionalProperties( - T obj, - object? additionalProperties = null - ) - { - if (additionalProperties == null) - { - return Serialize(obj); - } - var additionalPropertiesJsonNode = SerializeToNode(additionalProperties); - if (additionalPropertiesJsonNode is not JsonObject additionalPropertiesJsonObject) - { - throw new InvalidOperationException( - "The additional properties must serialize to a JSON object." - ); - } - var jsonNode = SerializeToNode(obj); - if (jsonNode is not JsonObject jsonObject) - { - throw new InvalidOperationException( - "The serialized object must be a JSON object to add properties." - ); - } - MergeJsonObjects(jsonObject, additionalPropertiesJsonObject); - return jsonObject.ToJsonString(JsonOptions.JsonSerializerOptions); - } - - private static void MergeJsonObjects(JsonObject baseObject, JsonObject overrideObject) - { - foreach (var property in overrideObject) - { - if (!baseObject.TryGetPropertyValue(property.Key, out JsonNode? existingValue)) - { - baseObject[property.Key] = - property.Value != null ? JsonNode.Parse(property.Value.ToJsonString()) : null; - continue; - } - if ( - existingValue is JsonObject nestedBaseObject - && property.Value is JsonObject nestedOverrideObject - ) - { - // If both values are objects, recursively merge them. - MergeJsonObjects(nestedBaseObject, nestedOverrideObject); - continue; - } - // Otherwise, the overrideObject takes precedence. - baseObject[property.Key] = - property.Value != null ? JsonNode.Parse(property.Value.ToJsonString()) : null; - } - } - - internal static T Deserialize(string json) => - JsonSerializer.Deserialize(json, JsonOptions.JsonSerializerOptions)!; -} diff --git a/seed/csharp-sdk/nullable-request-body/src/SeedApi.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/nullable-request-body/src/SeedApi.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/nullable-request-body/src/SeedApi.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/nullable-request-body/src/SeedApi.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/nullable-request-body/src/SeedApi.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/nullable-request-body/src/SeedApi.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..dc732084c15f --- /dev/null +++ b/seed/csharp-sdk/nullable-request-body/src/SeedApi.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedApi.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/nullable-request-body/src/SeedApi/Core/JsonConfiguration.cs b/seed/csharp-sdk/nullable-request-body/src/SeedApi/Core/JsonConfiguration.cs index 321193691456..df6b2c46945d 100644 --- a/seed/csharp-sdk/nullable-request-body/src/SeedApi/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/nullable-request-body/src/SeedApi/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/nullable-request-body/src/SeedApi/Core/NullableAttribute.cs b/seed/csharp-sdk/nullable-request-body/src/SeedApi/Core/NullableAttribute.cs new file mode 100644 index 000000000000..8fca30535a2a --- /dev/null +++ b/seed/csharp-sdk/nullable-request-body/src/SeedApi/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedApi.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/nullable-request-body/src/SeedApi/Core/Optional.cs b/seed/csharp-sdk/nullable-request-body/src/SeedApi/Core/Optional.cs new file mode 100644 index 000000000000..4a9691feca1f --- /dev/null +++ b/seed/csharp-sdk/nullable-request-body/src/SeedApi/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedApi.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/nullable-request-body/src/SeedApi/Core/OptionalAttribute.cs b/seed/csharp-sdk/nullable-request-body/src/SeedApi/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..b2bc05f7244f --- /dev/null +++ b/seed/csharp-sdk/nullable-request-body/src/SeedApi/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedApi.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/nullable-request-body/src/SeedApi/Core/RawClient.cs b/seed/csharp-sdk/nullable-request-body/src/SeedApi/Core/RawClient.cs index 72e34bbc0e89..b60968e4eb03 100644 --- a/seed/csharp-sdk/nullable-request-body/src/SeedApi/Core/RawClient.cs +++ b/seed/csharp-sdk/nullable-request-body/src/SeedApi/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/nullable/explicit-nullable-optional/.editorconfig b/seed/csharp-sdk/nullable/explicit-nullable-optional/.editorconfig new file mode 100644 index 000000000000..1e7a0adbac80 --- /dev/null +++ b/seed/csharp-sdk/nullable/explicit-nullable-optional/.editorconfig @@ -0,0 +1,35 @@ +root = true + +[*.cs] +resharper_arrange_object_creation_when_type_evident_highlighting = hint +resharper_auto_property_can_be_made_get_only_global_highlighting = hint +resharper_check_namespace_highlighting = hint +resharper_class_never_instantiated_global_highlighting = hint +resharper_class_never_instantiated_local_highlighting = hint +resharper_collection_never_updated_global_highlighting = hint +resharper_convert_type_check_pattern_to_null_check_highlighting = hint +resharper_inconsistent_naming_highlighting = hint +resharper_member_can_be_private_global_highlighting = hint +resharper_member_hides_static_from_outer_class_highlighting = hint +resharper_not_accessed_field_local_highlighting = hint +resharper_nullable_warning_suppression_is_used_highlighting = suggestion +resharper_partial_type_with_single_part_highlighting = hint +resharper_prefer_concrete_value_over_default_highlighting = none +resharper_private_field_can_be_converted_to_local_variable_highlighting = hint +resharper_property_can_be_made_init_only_global_highlighting = hint +resharper_property_can_be_made_init_only_local_highlighting = hint +resharper_redundant_name_qualifier_highlighting = none +resharper_redundant_using_directive_highlighting = hint +resharper_replace_slice_with_range_indexer_highlighting = none +resharper_unused_auto_property_accessor_global_highlighting = hint +resharper_unused_auto_property_accessor_local_highlighting = hint +resharper_unused_member_global_highlighting = hint +resharper_unused_type_global_highlighting = hint +resharper_use_string_interpolation_highlighting = hint +dotnet_diagnostic.CS1591.severity = suggestion + +[src/**/Types/*.cs] +resharper_check_namespace_highlighting = none + +[src/**/Core/Public/*.cs] +resharper_check_namespace_highlighting = none \ No newline at end of file diff --git a/seed/csharp-sdk/nullable/explicit-nullable-optional/.fern/metadata.json b/seed/csharp-sdk/nullable/explicit-nullable-optional/.fern/metadata.json new file mode 100644 index 000000000000..9a812ae689b2 --- /dev/null +++ b/seed/csharp-sdk/nullable/explicit-nullable-optional/.fern/metadata.json @@ -0,0 +1,9 @@ +{ + "cliVersion": "DUMMY", + "generatorName": "fernapi/fern-csharp-sdk", + "generatorVersion": "latest", + "generatorConfig": { + "experimental-explicit-nullable-optional": true + }, + "sdkVersion": "0.0.1" +} \ No newline at end of file diff --git a/seed/csharp-sdk/nullable/.github/workflows/ci.yml b/seed/csharp-sdk/nullable/explicit-nullable-optional/.github/workflows/ci.yml similarity index 100% rename from seed/csharp-sdk/nullable/.github/workflows/ci.yml rename to seed/csharp-sdk/nullable/explicit-nullable-optional/.github/workflows/ci.yml diff --git a/seed/csharp-sdk/nullable/explicit-nullable-optional/.gitignore b/seed/csharp-sdk/nullable/explicit-nullable-optional/.gitignore new file mode 100644 index 000000000000..11014f2b33d7 --- /dev/null +++ b/seed/csharp-sdk/nullable/explicit-nullable-optional/.gitignore @@ -0,0 +1,484 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +## This is based on `dotnet new gitignore` and customized by Fern + +# dotenv files +.env + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +# [Rr]elease/ (Ignored by Fern) +# [Rr]eleases/ (Ignored by Fern) +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +# [Ll]og/ (Ignored by Fern) +# [Ll]ogs/ (Ignored by Fern) + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET +project.lock.json +project.fragment.lock.json +artifacts/ + +# Tye +.tye/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml +.idea + +## +## Visual studio for Mac +## + + +# globs +Makefile.in +*.userprefs +*.usertasks +config.make +config.status +aclocal.m4 +install-sh +autom4te.cache/ +*.tar.gz +tarballs/ +test-results/ + +# Mac bundle stuff +*.dmg +*.app + +# content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# Vim temporary swap files +*.swp diff --git a/seed/csharp-sdk/nullable/explicit-nullable-optional/README.md b/seed/csharp-sdk/nullable/explicit-nullable-optional/README.md new file mode 100644 index 000000000000..11025318f622 --- /dev/null +++ b/seed/csharp-sdk/nullable/explicit-nullable-optional/README.md @@ -0,0 +1,123 @@ +# Seed C# Library + +[![fern shield](https://img.shields.io/badge/%F0%9F%8C%BF-Built%20with%20Fern-brightgreen)](https://buildwithfern.com?utm_source=github&utm_medium=github&utm_campaign=readme&utm_source=Seed%2FC%23) +[![nuget shield](https://img.shields.io/nuget/v/SeedNullable)](https://nuget.org/packages/SeedNullable) + +The Seed C# library provides convenient access to the Seed APIs from C#. + +## Table of Contents + +- [Requirements](#requirements) +- [Installation](#installation) +- [Reference](#reference) +- [Usage](#usage) +- [Exception Handling](#exception-handling) +- [Advanced](#advanced) + - [Retries](#retries) + - [Timeouts](#timeouts) +- [Contributing](#contributing) + +## Requirements + +This SDK requires: + +## Installation + +```sh +dotnet add package SeedNullable +``` + +## Reference + +A full reference for this library is available [here](./reference.md). + +## Usage + +Instantiate and use the client with the following: + +```csharp +using SeedNullable; + +var client = new SeedNullableClient(); +await client.Nullable.CreateUserAsync( + new CreateUserRequest + { + Username = "username", + Tags = new List() { "tags", "tags" }, + Metadata = new Metadata + { + CreatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000), + UpdatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000), + Avatar = "avatar", + Activated = true, + Status = new Status(new Status.Active()), + Values = new Dictionary() { { "values", "values" } }, + }, + Avatar = "avatar", + } +); +``` + +## Exception Handling + +When the API returns a non-success status code (4xx or 5xx response), a subclass of the following error +will be thrown. + +```csharp +using SeedNullable; + +try { + var response = await client.Nullable.CreateUserAsync(...); +} catch (SeedNullableApiException e) { + System.Console.WriteLine(e.Body); + System.Console.WriteLine(e.StatusCode); +} +``` + +## Advanced + +### Retries + +The SDK is instrumented with automatic retries with exponential backoff. A request will be retried as long +as the request is deemed retryable and the number of retry attempts has not grown larger than the configured +retry limit (default: 2). + +A request is deemed retryable when any of the following HTTP status codes is returned: + +- [408](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) (Timeout) +- [429](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) (Too Many Requests) +- [5XX](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500) (Internal Server Errors) + +Use the `MaxRetries` request option to configure this behavior. + +```csharp +var response = await client.Nullable.CreateUserAsync( + ..., + new RequestOptions { + MaxRetries: 0 // Override MaxRetries at the request level + } +); +``` + +### Timeouts + +The SDK defaults to a 30 second timeout. Use the `Timeout` option to configure this behavior. + +```csharp +var response = await client.Nullable.CreateUserAsync( + ..., + new RequestOptions { + Timeout: TimeSpan.FromSeconds(3) // Override timeout to 3s + } +); +``` + +## Contributing + +While we value open-source contributions to this SDK, this library is generated programmatically. +Additions made directly to this library would have to be moved over to our generation code, +otherwise they would be overwritten upon the next generated release. Feel free to open a PR as +a proof of concept, but know that we will not be able to merge it as-is. We suggest opening +an issue first to discuss with us! + +On the other hand, contributions to the README are always very welcome! \ No newline at end of file diff --git a/seed/csharp-sdk/nullable/explicit-nullable-optional/SeedNullable.slnx b/seed/csharp-sdk/nullable/explicit-nullable-optional/SeedNullable.slnx new file mode 100644 index 000000000000..c7ae36dbc8f7 --- /dev/null +++ b/seed/csharp-sdk/nullable/explicit-nullable-optional/SeedNullable.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/seed/csharp-sdk/nullable/explicit-nullable-optional/reference.md b/seed/csharp-sdk/nullable/explicit-nullable-optional/reference.md new file mode 100644 index 000000000000..6041309c00f2 --- /dev/null +++ b/seed/csharp-sdk/nullable/explicit-nullable-optional/reference.md @@ -0,0 +1,146 @@ +# Reference +## Nullable +
client.Nullable.GetUsersAsync(GetUsersRequest { ... }) -> IEnumerable<User> +
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```csharp +await client.Nullable.GetUsersAsync( + new GetUsersRequest + { + Usernames = ["usernames"], + Avatar = "avatar", + Activated = [true], + Tags = ["tags"], + Extra = true, + } +); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request:** `GetUsersRequest` + +
+
+
+
+ + +
+
+
+ +
client.Nullable.CreateUserAsync(CreateUserRequest { ... }) -> User +
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```csharp +await client.Nullable.CreateUserAsync( + new CreateUserRequest + { + Username = "username", + Tags = new List() { "tags", "tags" }, + Metadata = new Metadata + { + CreatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000), + UpdatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000), + Avatar = "avatar", + Activated = true, + Status = new Status(new Status.Active()), + Values = new Dictionary() { { "values", "values" } }, + }, + Avatar = "avatar", + } +); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request:** `CreateUserRequest` + +
+
+
+
+ + +
+
+
+ +
client.Nullable.DeleteUserAsync(DeleteUserRequest { ... }) -> bool +
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```csharp +await client.Nullable.DeleteUserAsync(new DeleteUserRequest { Username = "xy" }); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request:** `DeleteUserRequest` + +
+
+
+
+ + +
+
+
diff --git a/seed/csharp-sdk/nullable/explicit-nullable-optional/snippet.json b/seed/csharp-sdk/nullable/explicit-nullable-optional/snippet.json new file mode 100644 index 000000000000..91bc1566a122 --- /dev/null +++ b/seed/csharp-sdk/nullable/explicit-nullable-optional/snippet.json @@ -0,0 +1,41 @@ +{ + "types": {}, + "endpoints": [ + { + "example_identifier": null, + "id": { + "path": "/users", + "method": "GET", + "identifier_override": "endpoint_nullable.getUsers" + }, + "snippet": { + "type": "csharp", + "client": "using SeedNullable;\n\nvar client = new SeedNullableClient();\nawait client.Nullable.GetUsersAsync(\n new GetUsersRequest\n {\n Usernames = [\"usernames\"],\n Avatar = \"avatar\",\n Activated = [true],\n Tags = [\"tags\"],\n Extra = true,\n }\n);\n" + } + }, + { + "example_identifier": null, + "id": { + "path": "/users", + "method": "POST", + "identifier_override": "endpoint_nullable.createUser" + }, + "snippet": { + "type": "csharp", + "client": "using SeedNullable;\n\nvar client = new SeedNullableClient();\nawait client.Nullable.CreateUserAsync(\n new CreateUserRequest\n {\n Username = \"username\",\n Tags = new List() { \"tags\", \"tags\" },\n Metadata = new Metadata\n {\n CreatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000),\n UpdatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000),\n Avatar = \"avatar\",\n Activated = true,\n Status = new Status(new Status.Active()),\n Values = new Dictionary() { { \"values\", \"values\" } },\n },\n Avatar = \"avatar\",\n }\n);\n" + } + }, + { + "example_identifier": null, + "id": { + "path": "/users", + "method": "DELETE", + "identifier_override": "endpoint_nullable.deleteUser" + }, + "snippet": { + "type": "csharp", + "client": "using SeedNullable;\n\nvar client = new SeedNullableClient();\nawait client.Nullable.DeleteUserAsync(new DeleteUserRequest { Username = \"xy\" });\n" + } + } + ] +} \ No newline at end of file diff --git a/seed/csharp-sdk/nullable/src/SeedApi.DynamicSnippets/Example0.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedApi.DynamicSnippets/Example0.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedApi.DynamicSnippets/Example0.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedApi.DynamicSnippets/Example0.cs diff --git a/seed/csharp-sdk/nullable/src/SeedApi.DynamicSnippets/Example1.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedApi.DynamicSnippets/Example1.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedApi.DynamicSnippets/Example1.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedApi.DynamicSnippets/Example1.cs diff --git a/seed/csharp-sdk/nullable/src/SeedApi.DynamicSnippets/Example2.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedApi.DynamicSnippets/Example2.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedApi.DynamicSnippets/Example2.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedApi.DynamicSnippets/Example2.cs diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi.DynamicSnippets/SeedApi.DynamicSnippets.csproj b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedApi.DynamicSnippets/SeedApi.DynamicSnippets.csproj similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi.DynamicSnippets/SeedApi.DynamicSnippets.csproj rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedApi.DynamicSnippets/SeedApi.DynamicSnippets.csproj diff --git a/seed/csharp-sdk/nullable/src/SeedNullable.Test/Core/Json/AdditionalPropertiesTests.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable.Test/Core/Json/AdditionalPropertiesTests.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable.Test/Core/Json/AdditionalPropertiesTests.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable.Test/Core/Json/AdditionalPropertiesTests.cs diff --git a/seed/csharp-sdk/nullable/src/SeedNullable.Test/Core/Json/DateOnlyJsonTests.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable.Test/Core/Json/DateOnlyJsonTests.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable.Test/Core/Json/DateOnlyJsonTests.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable.Test/Core/Json/DateOnlyJsonTests.cs diff --git a/seed/csharp-sdk/nullable/src/SeedNullable.Test/Core/Json/DateTimeJsonTests.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable.Test/Core/Json/DateTimeJsonTests.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable.Test/Core/Json/DateTimeJsonTests.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable.Test/Core/Json/DateTimeJsonTests.cs diff --git a/seed/csharp-sdk/nullable/src/SeedNullable.Test/Core/Json/JsonAccessAttributeTests.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable.Test/Core/Json/JsonAccessAttributeTests.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable.Test/Core/Json/JsonAccessAttributeTests.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable.Test/Core/Json/JsonAccessAttributeTests.cs diff --git a/seed/csharp-sdk/nullable/src/SeedNullable.Test/Core/Json/OneOfSerializerTests.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable.Test/Core/Json/OneOfSerializerTests.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable.Test/Core/Json/OneOfSerializerTests.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable.Test/Core/Json/OneOfSerializerTests.cs diff --git a/seed/csharp-sdk/nullable/src/SeedNullable.Test/Core/Json/StringEnumSerializerTests.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable.Test/Core/Json/StringEnumSerializerTests.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable.Test/Core/Json/StringEnumSerializerTests.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable.Test/Core/Json/StringEnumSerializerTests.cs diff --git a/seed/csharp-sdk/nullable/src/SeedNullable.Test/Core/QueryStringConverterTests.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable.Test/Core/QueryStringConverterTests.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable.Test/Core/QueryStringConverterTests.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable.Test/Core/QueryStringConverterTests.cs diff --git a/seed/csharp-sdk/nullable/src/SeedNullable.Test/Core/RawClientTests/AdditionalHeadersTests.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable.Test/Core/RawClientTests/AdditionalHeadersTests.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable.Test/Core/RawClientTests/AdditionalHeadersTests.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable.Test/Core/RawClientTests/AdditionalHeadersTests.cs diff --git a/seed/csharp-sdk/nullable/src/SeedNullable.Test/Core/RawClientTests/AdditionalParametersTests.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable.Test/Core/RawClientTests/AdditionalParametersTests.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable.Test/Core/RawClientTests/AdditionalParametersTests.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable.Test/Core/RawClientTests/AdditionalParametersTests.cs diff --git a/seed/csharp-sdk/nullable/src/SeedNullable.Test/Core/RawClientTests/MultipartFormTests.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable.Test/Core/RawClientTests/MultipartFormTests.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable.Test/Core/RawClientTests/MultipartFormTests.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable.Test/Core/RawClientTests/MultipartFormTests.cs diff --git a/seed/csharp-sdk/nullable/src/SeedNullable.Test/Core/RawClientTests/QueryParameterTests.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable.Test/Core/RawClientTests/QueryParameterTests.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable.Test/Core/RawClientTests/QueryParameterTests.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable.Test/Core/RawClientTests/QueryParameterTests.cs diff --git a/seed/csharp-sdk/nullable/src/SeedNullable.Test/Core/RawClientTests/RetriesTests.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable.Test/Core/RawClientTests/RetriesTests.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable.Test/Core/RawClientTests/RetriesTests.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable.Test/Core/RawClientTests/RetriesTests.cs diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi.Test/SeedApi.Test.Custom.props b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable.Test/SeedNullable.Test.Custom.props similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi.Test/SeedApi.Test.Custom.props rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable.Test/SeedNullable.Test.Custom.props diff --git a/seed/csharp-sdk/nullable/src/SeedNullable.Test/SeedNullable.Test.csproj b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable.Test/SeedNullable.Test.csproj similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable.Test/SeedNullable.Test.csproj rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable.Test/SeedNullable.Test.csproj diff --git a/seed/csharp-sdk/nullable/src/SeedNullable.Test/TestClient.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable.Test/TestClient.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable.Test/TestClient.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable.Test/TestClient.cs diff --git a/seed/csharp-sdk/nullable/src/SeedNullable.Test/Unit/MockServer/BaseMockServerTest.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable.Test/Unit/MockServer/BaseMockServerTest.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable.Test/Unit/MockServer/BaseMockServerTest.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable.Test/Unit/MockServer/BaseMockServerTest.cs diff --git a/seed/csharp-sdk/nullable/src/SeedNullable.Test/Unit/MockServer/CreateUserTest.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable.Test/Unit/MockServer/CreateUserTest.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable.Test/Unit/MockServer/CreateUserTest.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable.Test/Unit/MockServer/CreateUserTest.cs diff --git a/seed/csharp-sdk/nullable/src/SeedNullable.Test/Unit/MockServer/DeleteUserTest.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable.Test/Unit/MockServer/DeleteUserTest.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable.Test/Unit/MockServer/DeleteUserTest.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable.Test/Unit/MockServer/DeleteUserTest.cs diff --git a/seed/csharp-sdk/nullable/src/SeedNullable.Test/Unit/MockServer/GetUsersTest.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable.Test/Unit/MockServer/GetUsersTest.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable.Test/Unit/MockServer/GetUsersTest.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable.Test/Unit/MockServer/GetUsersTest.cs diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi.Test/Utils/JsonElementComparer.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable.Test/Utils/JsonElementComparer.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi.Test/Utils/JsonElementComparer.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable.Test/Utils/JsonElementComparer.cs diff --git a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable.Test/Utils/NUnitExtensions.cs similarity index 92% rename from seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Utils/NUnitExtensions.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/nullable-optional/src/SeedNullableOptional.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi.Test/Utils/OneOfComparer.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable.Test/Utils/OneOfComparer.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi.Test/Utils/OneOfComparer.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable.Test/Utils/OneOfComparer.cs diff --git a/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..aa057ddc2c2e --- /dev/null +++ b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedNullable.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi.Test/Utils/ReadOnlyMemoryComparer.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable.Test/Utils/ReadOnlyMemoryComparer.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi.Test/Utils/ReadOnlyMemoryComparer.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable.Test/Utils/ReadOnlyMemoryComparer.cs diff --git a/seed/csharp-sdk/nullable/src/SeedNullable/Core/ApiResponse.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/ApiResponse.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable/Core/ApiResponse.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/ApiResponse.cs diff --git a/seed/csharp-sdk/nullable/src/SeedNullable/Core/BaseRequest.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/BaseRequest.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable/Core/BaseRequest.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/BaseRequest.cs diff --git a/seed/csharp-sdk/nullable/src/SeedNullable/Core/CollectionItemSerializer.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/CollectionItemSerializer.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable/Core/CollectionItemSerializer.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/CollectionItemSerializer.cs diff --git a/seed/csharp-sdk/nullable/src/SeedNullable/Core/Constants.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/Constants.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable/Core/Constants.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/Constants.cs diff --git a/seed/csharp-sdk/nullable/src/SeedNullable/Core/DateOnlyConverter.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/DateOnlyConverter.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable/Core/DateOnlyConverter.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/DateOnlyConverter.cs diff --git a/seed/csharp-sdk/nullable/src/SeedNullable/Core/DateTimeSerializer.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/DateTimeSerializer.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable/Core/DateTimeSerializer.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/DateTimeSerializer.cs diff --git a/seed/csharp-sdk/nullable/src/SeedNullable/Core/EmptyRequest.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/EmptyRequest.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable/Core/EmptyRequest.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/EmptyRequest.cs diff --git a/seed/csharp-sdk/nullable/src/SeedNullable/Core/EncodingCache.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/EncodingCache.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable/Core/EncodingCache.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/EncodingCache.cs diff --git a/seed/csharp-sdk/nullable/src/SeedNullable/Core/Extensions.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/Extensions.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable/Core/Extensions.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/Extensions.cs diff --git a/seed/csharp-sdk/nullable/src/SeedNullable/Core/FormUrlEncoder.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/FormUrlEncoder.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable/Core/FormUrlEncoder.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/FormUrlEncoder.cs diff --git a/seed/csharp-sdk/nullable/src/SeedNullable/Core/HeaderValue.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/HeaderValue.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable/Core/HeaderValue.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/HeaderValue.cs diff --git a/seed/csharp-sdk/nullable/src/SeedNullable/Core/Headers.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/Headers.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable/Core/Headers.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/Headers.cs diff --git a/seed/csharp-sdk/nullable/src/SeedNullable/Core/HttpMethodExtensions.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/HttpMethodExtensions.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable/Core/HttpMethodExtensions.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/HttpMethodExtensions.cs diff --git a/seed/csharp-sdk/nullable/src/SeedNullable/Core/IIsRetryableContent.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/IIsRetryableContent.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable/Core/IIsRetryableContent.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/IIsRetryableContent.cs diff --git a/seed/csharp-sdk/nullable/src/SeedNullable/Core/IRequestOptions.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/IRequestOptions.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable/Core/IRequestOptions.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/IRequestOptions.cs diff --git a/seed/csharp-sdk/nullable/src/SeedNullable/Core/JsonAccessAttribute.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/JsonAccessAttribute.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable/Core/JsonAccessAttribute.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/JsonAccessAttribute.cs diff --git a/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/JsonConfiguration.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/JsonConfiguration.cs new file mode 100644 index 000000000000..3ce6ded218cb --- /dev/null +++ b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/JsonConfiguration.cs @@ -0,0 +1,251 @@ +using global::System.Reflection; +using global::System.Text.Json; +using global::System.Text.Json.Nodes; +using global::System.Text.Json.Serialization; +using global::System.Text.Json.Serialization.Metadata; + +namespace SeedNullable.Core; + +internal static partial class JsonOptions +{ + internal static readonly JsonSerializerOptions JsonSerializerOptions; + + static JsonOptions() + { + var options = new JsonSerializerOptions + { + Converters = + { + new DateTimeSerializer(), +#if USE_PORTABLE_DATE_ONLY + new DateOnlyConverter(), +#endif + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, +#if DEBUG + WriteIndented = true, +#endif + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = new DefaultJsonTypeInfoResolver + { + Modifiers = + { + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, + }, + }, + }; + ConfigureJsonSerializerOptions(options); + JsonSerializerOptions = options; + } + + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); +} + +internal static class JsonUtils +{ + internal static string Serialize(T obj) => + JsonSerializer.Serialize(obj, JsonOptions.JsonSerializerOptions); + + internal static JsonElement SerializeToElement(T obj) => + JsonSerializer.SerializeToElement(obj, JsonOptions.JsonSerializerOptions); + + internal static JsonDocument SerializeToDocument(T obj) => + JsonSerializer.SerializeToDocument(obj, JsonOptions.JsonSerializerOptions); + + internal static JsonNode? SerializeToNode(T obj) => + JsonSerializer.SerializeToNode(obj, JsonOptions.JsonSerializerOptions); + + internal static byte[] SerializeToUtf8Bytes(T obj) => + JsonSerializer.SerializeToUtf8Bytes(obj, JsonOptions.JsonSerializerOptions); + + internal static string SerializeWithAdditionalProperties( + T obj, + object? additionalProperties = null + ) + { + if (additionalProperties == null) + { + return Serialize(obj); + } + var additionalPropertiesJsonNode = SerializeToNode(additionalProperties); + if (additionalPropertiesJsonNode is not JsonObject additionalPropertiesJsonObject) + { + throw new InvalidOperationException( + "The additional properties must serialize to a JSON object." + ); + } + var jsonNode = SerializeToNode(obj); + if (jsonNode is not JsonObject jsonObject) + { + throw new InvalidOperationException( + "The serialized object must be a JSON object to add properties." + ); + } + MergeJsonObjects(jsonObject, additionalPropertiesJsonObject); + return jsonObject.ToJsonString(JsonOptions.JsonSerializerOptions); + } + + private static void MergeJsonObjects(JsonObject baseObject, JsonObject overrideObject) + { + foreach (var property in overrideObject) + { + if (!baseObject.TryGetPropertyValue(property.Key, out JsonNode? existingValue)) + { + baseObject[property.Key] = + property.Value != null ? JsonNode.Parse(property.Value.ToJsonString()) : null; + continue; + } + if ( + existingValue is JsonObject nestedBaseObject + && property.Value is JsonObject nestedOverrideObject + ) + { + // If both values are objects, recursively merge them. + MergeJsonObjects(nestedBaseObject, nestedOverrideObject); + continue; + } + // Otherwise, the overrideObject takes precedence. + baseObject[property.Key] = + property.Value != null ? JsonNode.Parse(property.Value.ToJsonString()) : null; + } + } + + internal static T Deserialize(string json) => + JsonSerializer.Deserialize(json, JsonOptions.JsonSerializerOptions)!; +} diff --git a/seed/csharp-sdk/nullable/src/SeedNullable/Core/JsonRequest.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/JsonRequest.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable/Core/JsonRequest.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/JsonRequest.cs diff --git a/seed/csharp-sdk/nullable/src/SeedNullable/Core/MultipartFormRequest.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/MultipartFormRequest.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable/Core/MultipartFormRequest.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/MultipartFormRequest.cs diff --git a/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/NullableAttribute.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/NullableAttribute.cs new file mode 100644 index 000000000000..9d9f6631cb28 --- /dev/null +++ b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedNullable.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/nullable/src/SeedNullable/Core/OneOfSerializer.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/OneOfSerializer.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable/Core/OneOfSerializer.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/OneOfSerializer.cs diff --git a/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/Optional.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/Optional.cs new file mode 100644 index 000000000000..8bb251ff6413 --- /dev/null +++ b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedNullable.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/OptionalAttribute.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..6fd2bd6b45b9 --- /dev/null +++ b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedNullable.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/nullable/src/SeedNullable/Core/Public/AdditionalProperties.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/Public/AdditionalProperties.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable/Core/Public/AdditionalProperties.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/Public/AdditionalProperties.cs diff --git a/seed/csharp-sdk/nullable/src/SeedNullable/Core/Public/ClientOptions.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/Public/ClientOptions.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable/Core/Public/ClientOptions.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/Public/ClientOptions.cs diff --git a/seed/csharp-sdk/nullable/src/SeedNullable/Core/Public/FileParameter.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/Public/FileParameter.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable/Core/Public/FileParameter.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/Public/FileParameter.cs diff --git a/seed/csharp-sdk/nullable/src/SeedNullable/Core/Public/RequestOptions.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/Public/RequestOptions.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable/Core/Public/RequestOptions.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/Public/RequestOptions.cs diff --git a/seed/csharp-sdk/nullable/src/SeedNullable/Core/Public/SeedNullableApiException.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/Public/SeedNullableApiException.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable/Core/Public/SeedNullableApiException.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/Public/SeedNullableApiException.cs diff --git a/seed/csharp-sdk/nullable/src/SeedNullable/Core/Public/SeedNullableException.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/Public/SeedNullableException.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable/Core/Public/SeedNullableException.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/Public/SeedNullableException.cs diff --git a/seed/csharp-sdk/nullable/src/SeedNullable/Core/Public/Version.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/Public/Version.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable/Core/Public/Version.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/Public/Version.cs diff --git a/seed/csharp-sdk/nullable/src/SeedNullable/Core/QueryStringConverter.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/QueryStringConverter.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable/Core/QueryStringConverter.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/QueryStringConverter.cs diff --git a/seed/csharp-sdk/nullable/src/SeedNullable/Core/RawClient.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/RawClient.cs similarity index 99% rename from seed/csharp-sdk/nullable/src/SeedNullable/Core/RawClient.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/RawClient.cs index 77455b5d8394..bdb5b3542747 100644 --- a/seed/csharp-sdk/nullable/src/SeedNullable/Core/RawClient.cs +++ b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/nullable/src/SeedNullable/Core/StreamRequest.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/StreamRequest.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable/Core/StreamRequest.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/StreamRequest.cs diff --git a/seed/csharp-sdk/nullable/src/SeedNullable/Core/StringEnum.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/StringEnum.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable/Core/StringEnum.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/StringEnum.cs diff --git a/seed/csharp-sdk/nullable/src/SeedNullable/Core/StringEnumExtensions.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/StringEnumExtensions.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable/Core/StringEnumExtensions.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/StringEnumExtensions.cs diff --git a/seed/csharp-sdk/nullable/src/SeedNullable/Core/StringEnumSerializer.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/StringEnumSerializer.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable/Core/StringEnumSerializer.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/StringEnumSerializer.cs diff --git a/seed/csharp-sdk/nullable/src/SeedNullable/Core/ValueConvert.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/ValueConvert.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable/Core/ValueConvert.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Core/ValueConvert.cs diff --git a/seed/csharp-sdk/nullable/src/SeedNullable/ISeedNullableClient.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/ISeedNullableClient.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable/ISeedNullableClient.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/ISeedNullableClient.cs diff --git a/seed/csharp-sdk/nullable/src/SeedNullable/Nullable/INullableClient.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Nullable/INullableClient.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable/Nullable/INullableClient.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Nullable/INullableClient.cs diff --git a/seed/csharp-sdk/nullable/src/SeedNullable/Nullable/NullableClient.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Nullable/NullableClient.cs similarity index 99% rename from seed/csharp-sdk/nullable/src/SeedNullable/Nullable/NullableClient.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Nullable/NullableClient.cs index ed3e8fb4e61c..6b62e2047095 100644 --- a/seed/csharp-sdk/nullable/src/SeedNullable/Nullable/NullableClient.cs +++ b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Nullable/NullableClient.cs @@ -40,7 +40,7 @@ public async Task> GetUsersAsync( { _query["avatar"] = request.Avatar; } - if (request.Extra != null) + if (request.Extra.IsDefined) { _query["extra"] = JsonUtils.Serialize(request.Extra.Value); } diff --git a/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Nullable/Requests/CreateUserRequest.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Nullable/Requests/CreateUserRequest.cs new file mode 100644 index 000000000000..b8727571ce61 --- /dev/null +++ b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Nullable/Requests/CreateUserRequest.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Serialization; +using SeedNullable.Core; + +namespace SeedNullable; + +[Serializable] +public record CreateUserRequest +{ + [JsonPropertyName("username")] + public required string Username { get; set; } + + [Optional] + [JsonPropertyName("tags")] + public IEnumerable? Tags { get; set; } + + [Optional] + [JsonPropertyName("metadata")] + public Metadata? Metadata { get; set; } + + [Nullable, Optional] + [JsonPropertyName("avatar")] + public Optional Avatar { get; set; } + + /// + public override string ToString() + { + return JsonUtils.Serialize(this); + } +} diff --git a/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Nullable/Requests/DeleteUserRequest.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Nullable/Requests/DeleteUserRequest.cs new file mode 100644 index 000000000000..923a3cb1bae7 --- /dev/null +++ b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Nullable/Requests/DeleteUserRequest.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; +using SeedNullable.Core; + +namespace SeedNullable; + +[Serializable] +public record DeleteUserRequest +{ + /// + /// The user to delete. + /// + [Nullable, Optional] + [JsonPropertyName("username")] + public Optional Username { get; set; } + + /// + public override string ToString() + { + return JsonUtils.Serialize(this); + } +} diff --git a/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Nullable/Requests/GetUsersRequest.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Nullable/Requests/GetUsersRequest.cs new file mode 100644 index 000000000000..c8b8ceaccc65 --- /dev/null +++ b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Nullable/Requests/GetUsersRequest.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Serialization; +using SeedNullable.Core; + +namespace SeedNullable; + +[Serializable] +public record GetUsersRequest +{ + [JsonIgnore] + public IEnumerable Usernames { get; set; } = new List(); + + [JsonIgnore] + public string? Avatar { get; set; } + + [JsonIgnore] + public IEnumerable Activated { get; set; } = new List(); + + [JsonIgnore] + public IEnumerable Tags { get; set; } = new List(); + + [JsonIgnore] + public Optional Extra { get; set; } + + /// + public override string ToString() + { + return JsonUtils.Serialize(this); + } +} diff --git a/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Nullable/Types/Metadata.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Nullable/Types/Metadata.cs new file mode 100644 index 000000000000..99dc8f4e8efb --- /dev/null +++ b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Nullable/Types/Metadata.cs @@ -0,0 +1,46 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using SeedNullable.Core; + +namespace SeedNullable; + +[Serializable] +public record Metadata : IJsonOnDeserialized +{ + [JsonExtensionData] + private readonly IDictionary _extensionData = + new Dictionary(); + + [JsonPropertyName("createdAt")] + public required DateTime CreatedAt { get; set; } + + [JsonPropertyName("updatedAt")] + public required DateTime UpdatedAt { get; set; } + + [Nullable] + [JsonPropertyName("avatar")] + public string? Avatar { get; set; } + + [Nullable, Optional] + [JsonPropertyName("activated")] + public Optional Activated { get; set; } + + [JsonPropertyName("status")] + public required Status Status { get; set; } + + [Optional] + [JsonPropertyName("values")] + public Dictionary? Values { get; set; } + + [JsonIgnore] + public ReadOnlyAdditionalProperties AdditionalProperties { get; private set; } = new(); + + void IJsonOnDeserialized.OnDeserialized() => + AdditionalProperties.CopyFromExtensionData(_extensionData); + + /// + public override string ToString() + { + return JsonUtils.Serialize(this); + } +} diff --git a/seed/csharp-sdk/nullable/src/SeedNullable/Nullable/Types/Status.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Nullable/Types/Status.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable/Nullable/Types/Status.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Nullable/Types/Status.cs diff --git a/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Nullable/Types/User.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Nullable/Types/User.cs new file mode 100644 index 000000000000..8006412c6ffc --- /dev/null +++ b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/Nullable/Types/User.cs @@ -0,0 +1,55 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using OneOf; +using SeedNullable.Core; + +namespace SeedNullable; + +[Serializable] +public record User : IJsonOnDeserialized +{ + [JsonExtensionData] + private readonly IDictionary _extensionData = + new Dictionary(); + + [JsonPropertyName("name")] + public required string Name { get; set; } + + [JsonPropertyName("id")] + public required string Id { get; set; } + + [Nullable] + [JsonPropertyName("tags")] + public IEnumerable? Tags { get; set; } + + [Nullable, Optional] + [JsonPropertyName("metadata")] + public Optional Metadata { get; set; } + + [Nullable] + [JsonPropertyName("email")] + public string? Email { get; set; } + + [JsonPropertyName("favorite-number")] + public required OneOf, double> FavoriteNumber { get; set; } + + [Nullable, Optional] + [JsonPropertyName("numbers")] + public Optional?> Numbers { get; set; } + + [Nullable, Optional] + [JsonPropertyName("strings")] + public Optional?> Strings { get; set; } + + [JsonIgnore] + public ReadOnlyAdditionalProperties AdditionalProperties { get; private set; } = new(); + + void IJsonOnDeserialized.OnDeserialized() => + AdditionalProperties.CopyFromExtensionData(_extensionData); + + /// + public override string ToString() + { + return JsonUtils.Serialize(this); + } +} diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi/SeedApi.Custom.props b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/SeedNullable.Custom.props similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi/SeedApi.Custom.props rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/SeedNullable.Custom.props diff --git a/seed/csharp-sdk/nullable/src/SeedNullable/SeedNullable.csproj b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/SeedNullable.csproj similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable/SeedNullable.csproj rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/SeedNullable.csproj diff --git a/seed/csharp-sdk/nullable/src/SeedNullable/SeedNullableClient.cs b/seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/SeedNullableClient.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable/SeedNullableClient.cs rename to seed/csharp-sdk/nullable/explicit-nullable-optional/src/SeedNullable/SeedNullableClient.cs diff --git a/seed/csharp-sdk/nullable/no-custom-config/.editorconfig b/seed/csharp-sdk/nullable/no-custom-config/.editorconfig new file mode 100644 index 000000000000..1e7a0adbac80 --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/.editorconfig @@ -0,0 +1,35 @@ +root = true + +[*.cs] +resharper_arrange_object_creation_when_type_evident_highlighting = hint +resharper_auto_property_can_be_made_get_only_global_highlighting = hint +resharper_check_namespace_highlighting = hint +resharper_class_never_instantiated_global_highlighting = hint +resharper_class_never_instantiated_local_highlighting = hint +resharper_collection_never_updated_global_highlighting = hint +resharper_convert_type_check_pattern_to_null_check_highlighting = hint +resharper_inconsistent_naming_highlighting = hint +resharper_member_can_be_private_global_highlighting = hint +resharper_member_hides_static_from_outer_class_highlighting = hint +resharper_not_accessed_field_local_highlighting = hint +resharper_nullable_warning_suppression_is_used_highlighting = suggestion +resharper_partial_type_with_single_part_highlighting = hint +resharper_prefer_concrete_value_over_default_highlighting = none +resharper_private_field_can_be_converted_to_local_variable_highlighting = hint +resharper_property_can_be_made_init_only_global_highlighting = hint +resharper_property_can_be_made_init_only_local_highlighting = hint +resharper_redundant_name_qualifier_highlighting = none +resharper_redundant_using_directive_highlighting = hint +resharper_replace_slice_with_range_indexer_highlighting = none +resharper_unused_auto_property_accessor_global_highlighting = hint +resharper_unused_auto_property_accessor_local_highlighting = hint +resharper_unused_member_global_highlighting = hint +resharper_unused_type_global_highlighting = hint +resharper_use_string_interpolation_highlighting = hint +dotnet_diagnostic.CS1591.severity = suggestion + +[src/**/Types/*.cs] +resharper_check_namespace_highlighting = none + +[src/**/Core/Public/*.cs] +resharper_check_namespace_highlighting = none \ No newline at end of file diff --git a/seed/csharp-sdk/nullable/.fern/metadata.json b/seed/csharp-sdk/nullable/no-custom-config/.fern/metadata.json similarity index 100% rename from seed/csharp-sdk/nullable/.fern/metadata.json rename to seed/csharp-sdk/nullable/no-custom-config/.fern/metadata.json diff --git a/seed/csharp-sdk/nullable/no-custom-config/.github/workflows/ci.yml b/seed/csharp-sdk/nullable/no-custom-config/.github/workflows/ci.yml new file mode 100644 index 000000000000..2f4097f6d758 --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/.github/workflows/ci.yml @@ -0,0 +1,97 @@ +name: ci + +on: [push] + +env: + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true + DOTNET_NOLOGO: true + +jobs: + compile: + runs-on: ubuntu-latest + + steps: + - name: Checkout repo + uses: actions/checkout@v5 + + - uses: actions/checkout@master + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.x + + - name: Print .NET info + run: dotnet --info + + - name: Install tools + run: dotnet tool restore + + - name: Restore dependencies + run: dotnet restore src/SeedNullable/SeedNullable.csproj + + - name: Build Release + run: dotnet build src/SeedNullable/SeedNullable.csproj -c Release --no-restore + + unit-tests: + runs-on: ubuntu-latest + + steps: + - name: Checkout repo + uses: actions/checkout@v5 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.x + + - name: Print .NET info + run: dotnet --info + + - name: Install tools + run: | + dotnet tool restore + + - name: Restore dependencies + run: dotnet restore src/SeedNullable.Test/SeedNullable.Test.csproj + + - name: Build Release + run: dotnet build src/SeedNullable.Test/SeedNullable.Test.csproj -c Release --no-restore + + - name: Run Tests + run: dotnet test src/SeedNullable.Test/SeedNullable.Test.csproj -c Release --no-build --no-restore + + + publish: + needs: [compile] + if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') + runs-on: ubuntu-latest + + steps: + - name: Checkout repo + uses: actions/checkout@v5 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.x + + - name: Print .NET info + run: dotnet --info + + - name: Install tools + run: dotnet tool restore + + - name: Restore dependencies + run: dotnet restore src/SeedNullable/SeedNullable.csproj + + - name: Build Release + run: dotnet build src/SeedNullable/SeedNullable.csproj -c Release --no-restore + + - name: Publish + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_TOKEN }} + run: | + dotnet pack src/SeedNullable/SeedNullable.csproj -c Release --no-build --no-restore + dotnet nuget push src/SeedNullable/bin/Release/*.nupkg --api-key $NUGET_API_KEY --source "nuget.org" + diff --git a/seed/csharp-sdk/nullable/no-custom-config/.gitignore b/seed/csharp-sdk/nullable/no-custom-config/.gitignore new file mode 100644 index 000000000000..11014f2b33d7 --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/.gitignore @@ -0,0 +1,484 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +## This is based on `dotnet new gitignore` and customized by Fern + +# dotenv files +.env + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +# [Rr]elease/ (Ignored by Fern) +# [Rr]eleases/ (Ignored by Fern) +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +# [Ll]og/ (Ignored by Fern) +# [Ll]ogs/ (Ignored by Fern) + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET +project.lock.json +project.fragment.lock.json +artifacts/ + +# Tye +.tye/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml +.idea + +## +## Visual studio for Mac +## + + +# globs +Makefile.in +*.userprefs +*.usertasks +config.make +config.status +aclocal.m4 +install-sh +autom4te.cache/ +*.tar.gz +tarballs/ +test-results/ + +# Mac bundle stuff +*.dmg +*.app + +# content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# Vim temporary swap files +*.swp diff --git a/seed/csharp-sdk/nullable/no-custom-config/README.md b/seed/csharp-sdk/nullable/no-custom-config/README.md new file mode 100644 index 000000000000..57198fea48fa --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/README.md @@ -0,0 +1,123 @@ +# Seed C# Library + +[![fern shield](https://img.shields.io/badge/%F0%9F%8C%BF-Built%20with%20Fern-brightgreen)](https://buildwithfern.com?utm_source=github&utm_medium=github&utm_campaign=readme&utm_source=Seed%2FC%23) +[![nuget shield](https://img.shields.io/nuget/v/SeedNullable)](https://nuget.org/packages/SeedNullable) + +The Seed C# library provides convenient access to the Seed APIs from C#. + +## Table of Contents + +- [Requirements](#requirements) +- [Installation](#installation) +- [Reference](#reference) +- [Usage](#usage) +- [Exception Handling](#exception-handling) +- [Advanced](#advanced) + - [Retries](#retries) + - [Timeouts](#timeouts) +- [Contributing](#contributing) + +## Requirements + +This SDK requires: + +## Installation + +```sh +dotnet add package SeedNullable +``` + +## Reference + +A full reference for this library is available [here](./reference.md). + +## Usage + +Instantiate and use the client with the following: + +```csharp +using SeedNullable; + +var client = new SeedNullableClient(); +await client.Nullable.CreateUserAsync( + new CreateUserRequest + { + Username = "username", + Tags = new List() { "tags", "tags" }, + Metadata = new Metadata + { + CreatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000), + UpdatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000), + Avatar = "avatar", + Activated = true, + Status = new Status(new Status.Active()), + Values = new Dictionary() { { "values", "values" } }, + }, + Avatar = "avatar", + } +); +``` + +## Exception Handling + +When the API returns a non-success status code (4xx or 5xx response), a subclass of the following error +will be thrown. + +```csharp +using SeedNullable; + +try { + var response = await client.Nullable.CreateUserAsync(...); +} catch (SeedNullableApiException e) { + System.Console.WriteLine(e.Body); + System.Console.WriteLine(e.StatusCode); +} +``` + +## Advanced + +### Retries + +The SDK is instrumented with automatic retries with exponential backoff. A request will be retried as long +as the request is deemed retryable and the number of retry attempts has not grown larger than the configured +retry limit (default: 2). + +A request is deemed retryable when any of the following HTTP status codes is returned: + +- [408](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) (Timeout) +- [429](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) (Too Many Requests) +- [5XX](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500) (Internal Server Errors) + +Use the `MaxRetries` request option to configure this behavior. + +```csharp +var response = await client.Nullable.CreateUserAsync( + ..., + new RequestOptions { + MaxRetries: 0 // Override MaxRetries at the request level + } +); +``` + +### Timeouts + +The SDK defaults to a 30 second timeout. Use the `Timeout` option to configure this behavior. + +```csharp +var response = await client.Nullable.CreateUserAsync( + ..., + new RequestOptions { + Timeout: TimeSpan.FromSeconds(3) // Override timeout to 3s + } +); +``` + +## Contributing + +While we value open-source contributions to this SDK, this library is generated programmatically. +Additions made directly to this library would have to be moved over to our generation code, +otherwise they would be overwritten upon the next generated release. Feel free to open a PR as +a proof of concept, but know that we will not be able to merge it as-is. We suggest opening +an issue first to discuss with us! + +On the other hand, contributions to the README are always very welcome! \ No newline at end of file diff --git a/seed/csharp-sdk/nullable/no-custom-config/SeedNullable.slnx b/seed/csharp-sdk/nullable/no-custom-config/SeedNullable.slnx new file mode 100644 index 000000000000..c7ae36dbc8f7 --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/SeedNullable.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/seed/csharp-sdk/nullable/no-custom-config/reference.md b/seed/csharp-sdk/nullable/no-custom-config/reference.md new file mode 100644 index 000000000000..c22ab8cc1eb9 --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/reference.md @@ -0,0 +1,146 @@ +# Reference +## Nullable +
client.Nullable.GetUsersAsync(GetUsersRequest { ... }) -> IEnumerable<User> +
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```csharp +await client.Nullable.GetUsersAsync( + new GetUsersRequest + { + Usernames = ["usernames"], + Avatar = "avatar", + Activated = [true], + Tags = ["tags"], + Extra = true, + } +); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request:** `GetUsersRequest` + +
+
+
+
+ + +
+
+
+ +
client.Nullable.CreateUserAsync(CreateUserRequest { ... }) -> User +
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```csharp +await client.Nullable.CreateUserAsync( + new CreateUserRequest + { + Username = "username", + Tags = new List() { "tags", "tags" }, + Metadata = new Metadata + { + CreatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000), + UpdatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000), + Avatar = "avatar", + Activated = true, + Status = new Status(new Status.Active()), + Values = new Dictionary() { { "values", "values" } }, + }, + Avatar = "avatar", + } +); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request:** `CreateUserRequest` + +
+
+
+
+ + +
+
+
+ +
client.Nullable.DeleteUserAsync(DeleteUserRequest { ... }) -> bool +
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```csharp +await client.Nullable.DeleteUserAsync(new DeleteUserRequest { Username = "xy" }); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request:** `DeleteUserRequest` + +
+
+
+
+ + +
+
+
diff --git a/seed/csharp-sdk/nullable/no-custom-config/snippet.json b/seed/csharp-sdk/nullable/no-custom-config/snippet.json new file mode 100644 index 000000000000..74d496cbefd1 --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/snippet.json @@ -0,0 +1,41 @@ +{ + "types": {}, + "endpoints": [ + { + "example_identifier": null, + "id": { + "path": "/users", + "method": "GET", + "identifier_override": "endpoint_nullable.getUsers" + }, + "snippet": { + "type": "csharp", + "client": "using SeedNullable;\n\nvar client = new SeedNullableClient();\nawait client.Nullable.GetUsersAsync(\n new GetUsersRequest\n {\n Usernames = [\"usernames\"],\n Avatar = \"avatar\",\n Activated = [true],\n Tags = [\"tags\"],\n Extra = true,\n }\n);\n" + } + }, + { + "example_identifier": null, + "id": { + "path": "/users", + "method": "POST", + "identifier_override": "endpoint_nullable.createUser" + }, + "snippet": { + "type": "csharp", + "client": "using SeedNullable;\n\nvar client = new SeedNullableClient();\nawait client.Nullable.CreateUserAsync(\n new CreateUserRequest\n {\n Username = \"username\",\n Tags = new List() { \"tags\", \"tags\" },\n Metadata = new Metadata\n {\n CreatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000),\n UpdatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000),\n Avatar = \"avatar\",\n Activated = true,\n Status = new Status(new Status.Active()),\n Values = new Dictionary() { { \"values\", \"values\" } },\n },\n Avatar = \"avatar\",\n }\n);\n" + } + }, + { + "example_identifier": null, + "id": { + "path": "/users", + "method": "DELETE", + "identifier_override": "endpoint_nullable.deleteUser" + }, + "snippet": { + "type": "csharp", + "client": "using SeedNullable;\n\nvar client = new SeedNullableClient();\nawait client.Nullable.DeleteUserAsync(new DeleteUserRequest { Username = \"xy\" });\n" + } + } + ] +} \ No newline at end of file diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedApi.DynamicSnippets/Example0.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedApi.DynamicSnippets/Example0.cs new file mode 100644 index 000000000000..f518c7a63427 --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedApi.DynamicSnippets/Example0.cs @@ -0,0 +1,34 @@ +using SeedNullable; + +namespace Usage; + +public class Example0 +{ + public async Task Do() { + var client = new SeedNullableClient( + clientOptions: new ClientOptions { + BaseUrl = "https://api.fern.com" + } + ); + + await client.Nullable.GetUsersAsync( + new GetUsersRequest { + Usernames = new List(){ + "usernames", + } + , + Avatar = "avatar", + Activated = new List(){ + true, + } + , + Tags = new List(){ + "tags", + } + , + Extra = true + } + ); + } + +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedApi.DynamicSnippets/Example1.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedApi.DynamicSnippets/Example1.cs new file mode 100644 index 000000000000..ac4e792a08fa --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedApi.DynamicSnippets/Example1.cs @@ -0,0 +1,41 @@ +using SeedNullable; +using System.Globalization; + +namespace Usage; + +public class Example1 +{ + public async Task Do() { + var client = new SeedNullableClient( + clientOptions: new ClientOptions { + BaseUrl = "https://api.fern.com" + } + ); + + await client.Nullable.CreateUserAsync( + new CreateUserRequest { + Username = "username", + Tags = new List(){ + "tags", + "tags", + } + , + Metadata = new Metadata { + CreatedAt = DateTime.Parse("2024-01-15T09:30:00Z", null, DateTimeStyles.AdjustToUniversal), + UpdatedAt = DateTime.Parse("2024-01-15T09:30:00Z", null, DateTimeStyles.AdjustToUniversal), + Avatar = "avatar", + Activated = true, + Status = new Status( + new Status.Active() + ), + Values = new Dictionary(){ + ["values"] = "values", + } + + }, + Avatar = "avatar" + } + ); + } + +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedApi.DynamicSnippets/Example2.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedApi.DynamicSnippets/Example2.cs new file mode 100644 index 000000000000..fd3302d90936 --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedApi.DynamicSnippets/Example2.cs @@ -0,0 +1,21 @@ +using SeedNullable; + +namespace Usage; + +public class Example2 +{ + public async Task Do() { + var client = new SeedNullableClient( + clientOptions: new ClientOptions { + BaseUrl = "https://api.fern.com" + } + ); + + await client.Nullable.DeleteUserAsync( + new DeleteUserRequest { + Username = "xy" + } + ); + } + +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedApi.DynamicSnippets/SeedApi.DynamicSnippets.csproj b/seed/csharp-sdk/nullable/no-custom-config/src/SeedApi.DynamicSnippets/SeedApi.DynamicSnippets.csproj new file mode 100644 index 000000000000..3417db2e58e2 --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedApi.DynamicSnippets/SeedApi.DynamicSnippets.csproj @@ -0,0 +1,13 @@ + + + + net8.0 + 12 + enable + enable + + + + + + \ No newline at end of file diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Core/Json/AdditionalPropertiesTests.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Core/Json/AdditionalPropertiesTests.cs new file mode 100644 index 000000000000..0b67d5469413 --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Core/Json/AdditionalPropertiesTests.cs @@ -0,0 +1,365 @@ +using global::System.Text.Json; +using global::System.Text.Json.Serialization; +using NUnit.Framework; +using SeedNullable.Core; + +namespace SeedNullable.Test.Core.Json; + +[TestFixture] +public class AdditionalPropertiesTests +{ + [Test] + public void Record_OnDeserialized_ShouldPopulateAdditionalProperties() + { + // Arrange + const string json = """ + { + "id": "1", + "category": "fiction", + "title": "The Hobbit" + } + """; + + // Act + var record = JsonUtils.Deserialize(json); + + // Assert + Assert.That(record, Is.Not.Null); + Assert.Multiple(() => + { + Assert.That(record.Id, Is.EqualTo("1")); + Assert.That(record.AdditionalProperties["category"].GetString(), Is.EqualTo("fiction")); + Assert.That(record.AdditionalProperties["title"].GetString(), Is.EqualTo("The Hobbit")); + }); + } + + [Test] + public void RecordWithWriteableAdditionalProperties_OnSerialization_ShouldIncludeAdditionalProperties() + { + // Arrange + var record = new WriteableRecord + { + Id = "1", + AdditionalProperties = { ["category"] = "fiction", ["title"] = "The Hobbit" }, + }; + + // Act + var json = JsonUtils.Serialize(record); + var deserializedRecord = JsonUtils.Deserialize(json); + + // Assert + Assert.That(deserializedRecord, Is.Not.Null); + Assert.Multiple(() => + { + Assert.That(deserializedRecord.Id, Is.EqualTo("1")); + Assert.That( + deserializedRecord.AdditionalProperties["category"], + Is.InstanceOf() + ); + Assert.That( + ((JsonElement)deserializedRecord.AdditionalProperties["category"]!).GetString(), + Is.EqualTo("fiction") + ); + Assert.That( + deserializedRecord.AdditionalProperties["title"], + Is.InstanceOf() + ); + Assert.That( + ((JsonElement)deserializedRecord.AdditionalProperties["title"]!).GetString(), + Is.EqualTo("The Hobbit") + ); + }); + } + + [Test] + public void ReadOnlyAdditionalProperties_ShouldRetrieveValuesCorrectly() + { + // Arrange + var extensionData = new Dictionary + { + ["key1"] = JsonUtils.SerializeToElement("value1"), + ["key2"] = JsonUtils.SerializeToElement(123), + }; + var readOnlyProps = new ReadOnlyAdditionalProperties(); + readOnlyProps.CopyFromExtensionData(extensionData); + + // Act & Assert + Assert.That(readOnlyProps["key1"].GetString(), Is.EqualTo("value1")); + Assert.That(readOnlyProps["key2"].GetInt32(), Is.EqualTo(123)); + } + + [Test] + public void AdditionalProperties_ShouldBehaveAsDictionary() + { + // Arrange + var additionalProps = new AdditionalProperties { ["key1"] = "value1", ["key2"] = 123 }; + + // Act + additionalProps["key3"] = true; + + // Assert + Assert.Multiple(() => + { + Assert.That(additionalProps["key1"], Is.EqualTo("value1")); + Assert.That(additionalProps["key2"], Is.EqualTo(123)); + Assert.That((bool)additionalProps["key3"]!, Is.True); + Assert.That(additionalProps.Count, Is.EqualTo(3)); + }); + } + + [Test] + public void AdditionalProperties_ToJsonObject_ShouldSerializeCorrectly() + { + // Arrange + var additionalProps = new AdditionalProperties { ["key1"] = "value1", ["key2"] = 123 }; + + // Act + var jsonObject = additionalProps.ToJsonObject(); + + Assert.Multiple(() => + { + // Assert + Assert.That(jsonObject["key1"]!.GetValue(), Is.EqualTo("value1")); + Assert.That(jsonObject["key2"]!.GetValue(), Is.EqualTo(123)); + }); + } + + [Test] + public void AdditionalProperties_MixReadAndWrite_ShouldOverwriteDeserializedProperty() + { + // Arrange + const string json = """ + { + "id": "1", + "category": "fiction", + "title": "The Hobbit" + } + """; + var record = JsonUtils.Deserialize(json); + + // Act + record.AdditionalProperties["category"] = "non-fiction"; + + // Assert + Assert.Multiple(() => + { + Assert.That(record, Is.Not.Null); + Assert.That(record.Id, Is.EqualTo("1")); + Assert.That(record.AdditionalProperties["category"], Is.EqualTo("non-fiction")); + Assert.That(record.AdditionalProperties["title"], Is.InstanceOf()); + Assert.That( + ((JsonElement)record.AdditionalProperties["title"]!).GetString(), + Is.EqualTo("The Hobbit") + ); + }); + } + + [Test] + public void RecordWithReadonlyAdditionalPropertiesInts_OnDeserialized_ShouldPopulateAdditionalProperties() + { + // Arrange + const string json = """ + { + "extra1": 42, + "extra2": 99 + } + """; + + // Act + var record = JsonUtils.Deserialize(json); + + // Assert + Assert.That(record, Is.Not.Null); + Assert.Multiple(() => + { + Assert.That(record.AdditionalProperties["extra1"], Is.EqualTo(42)); + Assert.That(record.AdditionalProperties["extra2"], Is.EqualTo(99)); + }); + } + + [Test] + public void RecordWithAdditionalPropertiesInts_OnSerialization_ShouldIncludeAdditionalProperties() + { + // Arrange + var record = new WriteableRecordWithInts + { + AdditionalProperties = { ["extra1"] = 42, ["extra2"] = 99 }, + }; + + // Act + var json = JsonUtils.Serialize(record); + var deserializedRecord = JsonUtils.Deserialize(json); + + // Assert + Assert.That(deserializedRecord, Is.Not.Null); + Assert.Multiple(() => + { + Assert.That(deserializedRecord.AdditionalProperties["extra1"], Is.EqualTo(42)); + Assert.That(deserializedRecord.AdditionalProperties["extra2"], Is.EqualTo(99)); + }); + } + + [Test] + public void RecordWithReadonlyAdditionalPropertiesDictionaries_OnDeserialized_ShouldPopulateAdditionalProperties() + { + // Arrange + const string json = """ + { + "extra1": { "key1": true, "key2": false }, + "extra2": { "key3": true } + } + """; + + // Act + var record = JsonUtils.Deserialize(json); + + // Assert + Assert.That(record, Is.Not.Null); + Assert.Multiple(() => + { + Assert.That(record.AdditionalProperties["extra1"]["key1"], Is.True); + Assert.That(record.AdditionalProperties["extra1"]["key2"], Is.False); + Assert.That(record.AdditionalProperties["extra2"]["key3"], Is.True); + }); + } + + [Test] + public void RecordWithAdditionalPropertiesDictionaries_OnSerialization_ShouldIncludeAdditionalProperties() + { + // Arrange + var record = new WriteableRecordWithDictionaries + { + AdditionalProperties = + { + ["extra1"] = new Dictionary { { "key1", true }, { "key2", false } }, + ["extra2"] = new Dictionary { { "key3", true } }, + }, + }; + + // Act + var json = JsonUtils.Serialize(record); + var deserializedRecord = JsonUtils.Deserialize(json); + + // Assert + Assert.That(deserializedRecord, Is.Not.Null); + Assert.Multiple(() => + { + Assert.That(deserializedRecord.AdditionalProperties["extra1"]["key1"], Is.True); + Assert.That(deserializedRecord.AdditionalProperties["extra1"]["key2"], Is.False); + Assert.That(deserializedRecord.AdditionalProperties["extra2"]["key3"], Is.True); + }); + } + + private record Record : IJsonOnDeserialized + { + [JsonPropertyName("id")] + public required string Id { get; set; } + + [JsonExtensionData] + private readonly IDictionary _extensionData = + new Dictionary(); + + [JsonIgnore] + public ReadOnlyAdditionalProperties AdditionalProperties { get; } = new(); + + void IJsonOnDeserialized.OnDeserialized() + { + AdditionalProperties.CopyFromExtensionData(_extensionData); + } + } + + private record WriteableRecord : IJsonOnDeserialized, IJsonOnSerializing + { + [JsonPropertyName("id")] + public required string Id { get; set; } + + [JsonExtensionData] + private readonly IDictionary _extensionData = + new Dictionary(); + + [JsonIgnore] + public AdditionalProperties AdditionalProperties { get; set; } = new(); + + void IJsonOnDeserialized.OnDeserialized() + { + AdditionalProperties.CopyFromExtensionData(_extensionData); + } + + void IJsonOnSerializing.OnSerializing() + { + AdditionalProperties.CopyToExtensionData(_extensionData); + } + } + + private record RecordWithInts : IJsonOnDeserialized + { + [JsonExtensionData] + private readonly IDictionary _extensionData = + new Dictionary(); + + [JsonIgnore] + public ReadOnlyAdditionalProperties AdditionalProperties { get; } = new(); + + void IJsonOnDeserialized.OnDeserialized() + { + AdditionalProperties.CopyFromExtensionData(_extensionData); + } + } + + private record WriteableRecordWithInts : IJsonOnDeserialized, IJsonOnSerializing + { + [JsonExtensionData] + private readonly IDictionary _extensionData = + new Dictionary(); + + [JsonIgnore] + public AdditionalProperties AdditionalProperties { get; } = new(); + + void IJsonOnDeserialized.OnDeserialized() + { + AdditionalProperties.CopyFromExtensionData(_extensionData); + } + + void IJsonOnSerializing.OnSerializing() + { + AdditionalProperties.CopyToExtensionData(_extensionData); + } + } + + private record RecordWithDictionaries : IJsonOnDeserialized + { + [JsonExtensionData] + private readonly IDictionary _extensionData = + new Dictionary(); + + [JsonIgnore] + public ReadOnlyAdditionalProperties< + Dictionary + > AdditionalProperties { get; } = new(); + + void IJsonOnDeserialized.OnDeserialized() + { + AdditionalProperties.CopyFromExtensionData(_extensionData); + } + } + + private record WriteableRecordWithDictionaries : IJsonOnDeserialized, IJsonOnSerializing + { + [JsonExtensionData] + private readonly IDictionary _extensionData = + new Dictionary(); + + [JsonIgnore] + public AdditionalProperties> AdditionalProperties { get; } = new(); + + void IJsonOnDeserialized.OnDeserialized() + { + AdditionalProperties.CopyFromExtensionData(_extensionData); + } + + void IJsonOnSerializing.OnSerializing() + { + AdditionalProperties.CopyToExtensionData(_extensionData); + } + } +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Core/Json/DateOnlyJsonTests.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Core/Json/DateOnlyJsonTests.cs new file mode 100644 index 000000000000..c21692f5fe1c --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Core/Json/DateOnlyJsonTests.cs @@ -0,0 +1,76 @@ +using NUnit.Framework; +using SeedNullable.Core; + +namespace SeedNullable.Test.Core.Json; + +[TestFixture] +public class DateOnlyJsonTests +{ + [Test] + public void SerializeDateOnly_ShouldMatchExpectedFormat() + { + (DateOnly dateOnly, string expected)[] testCases = + [ + (new DateOnly(2023, 10, 5), "\"2023-10-05\""), + (new DateOnly(2023, 1, 1), "\"2023-01-01\""), + (new DateOnly(2023, 12, 31), "\"2023-12-31\""), + (new DateOnly(2023, 6, 15), "\"2023-06-15\""), + (new DateOnly(2023, 3, 10), "\"2023-03-10\""), + ]; + foreach (var (dateOnly, expected) in testCases) + { + var json = JsonUtils.Serialize(dateOnly); + Assert.That(json, Is.EqualTo(expected)); + } + } + + [Test] + public void DeserializeDateOnly_ShouldMatchExpectedDateOnly() + { + (DateOnly expected, string json)[] testCases = + [ + (new DateOnly(2023, 10, 5), "\"2023-10-05\""), + (new DateOnly(2023, 1, 1), "\"2023-01-01\""), + (new DateOnly(2023, 12, 31), "\"2023-12-31\""), + (new DateOnly(2023, 6, 15), "\"2023-06-15\""), + (new DateOnly(2023, 3, 10), "\"2023-03-10\""), + ]; + + foreach (var (expected, json) in testCases) + { + var dateOnly = JsonUtils.Deserialize(json); + Assert.That(dateOnly, Is.EqualTo(expected)); + } + } + + [Test] + public void SerializeNullableDateOnly_ShouldMatchExpectedFormat() + { + (DateOnly? dateOnly, string expected)[] testCases = + [ + (new DateOnly(2023, 10, 5), "\"2023-10-05\""), + (null, "null"), + ]; + foreach (var (dateOnly, expected) in testCases) + { + var json = JsonUtils.Serialize(dateOnly); + Assert.That(json, Is.EqualTo(expected)); + } + } + + [Test] + public void DeserializeNullableDateOnly_ShouldMatchExpectedDateOnly() + { + (DateOnly? expected, string json)[] testCases = + [ + (new DateOnly(2023, 10, 5), "\"2023-10-05\""), + (null, "null"), + ]; + + foreach (var (expected, json) in testCases) + { + var dateOnly = JsonUtils.Deserialize(json); + Assert.That(dateOnly, Is.EqualTo(expected)); + } + } +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Core/Json/DateTimeJsonTests.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Core/Json/DateTimeJsonTests.cs new file mode 100644 index 000000000000..3ad543573791 --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Core/Json/DateTimeJsonTests.cs @@ -0,0 +1,110 @@ +using NUnit.Framework; +using SeedNullable.Core; + +namespace SeedNullable.Test.Core.Json; + +[TestFixture] +public class DateTimeJsonTests +{ + [Test] + public void SerializeDateTime_ShouldMatchExpectedFormat() + { + (DateTime dateTime, string expected)[] testCases = + [ + ( + new DateTime(2023, 10, 5, 14, 30, 0, DateTimeKind.Utc), + "\"2023-10-05T14:30:00.000Z\"" + ), + (new DateTime(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc), "\"2023-01-01T00:00:00.000Z\""), + ( + new DateTime(2023, 12, 31, 23, 59, 59, DateTimeKind.Utc), + "\"2023-12-31T23:59:59.000Z\"" + ), + (new DateTime(2023, 6, 15, 12, 0, 0, DateTimeKind.Utc), "\"2023-06-15T12:00:00.000Z\""), + ( + new DateTime(2023, 3, 10, 8, 45, 30, DateTimeKind.Utc), + "\"2023-03-10T08:45:30.000Z\"" + ), + ( + new DateTime(2023, 3, 10, 8, 45, 30, 123, DateTimeKind.Utc), + "\"2023-03-10T08:45:30.123Z\"" + ), + ]; + foreach (var (dateTime, expected) in testCases) + { + var json = JsonUtils.Serialize(dateTime); + Assert.That(json, Is.EqualTo(expected)); + } + } + + [Test] + public void DeserializeDateTime_ShouldMatchExpectedDateTime() + { + (DateTime expected, string json)[] testCases = + [ + ( + new DateTime(2023, 10, 5, 14, 30, 0, DateTimeKind.Utc), + "\"2023-10-05T14:30:00.000Z\"" + ), + (new DateTime(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc), "\"2023-01-01T00:00:00.000Z\""), + ( + new DateTime(2023, 12, 31, 23, 59, 59, DateTimeKind.Utc), + "\"2023-12-31T23:59:59.000Z\"" + ), + (new DateTime(2023, 6, 15, 12, 0, 0, DateTimeKind.Utc), "\"2023-06-15T12:00:00.000Z\""), + ( + new DateTime(2023, 3, 10, 8, 45, 30, DateTimeKind.Utc), + "\"2023-03-10T08:45:30.000Z\"" + ), + (new DateTime(2023, 3, 10, 8, 45, 30, DateTimeKind.Utc), "\"2023-03-10T08:45:30Z\""), + ( + new DateTime(2023, 3, 10, 8, 45, 30, 123, DateTimeKind.Utc), + "\"2023-03-10T08:45:30.123Z\"" + ), + ]; + + foreach (var (expected, json) in testCases) + { + var dateTime = JsonUtils.Deserialize(json); + Assert.That(dateTime, Is.EqualTo(expected)); + } + } + + [Test] + public void SerializeNullableDateTime_ShouldMatchExpectedFormat() + { + (DateTime? expected, string json)[] testCases = + [ + ( + new DateTime(2023, 10, 5, 14, 30, 0, DateTimeKind.Utc), + "\"2023-10-05T14:30:00.000Z\"" + ), + (null, "null"), + ]; + + foreach (var (expected, json) in testCases) + { + var dateTime = JsonUtils.Deserialize(json); + Assert.That(dateTime, Is.EqualTo(expected)); + } + } + + [Test] + public void DeserializeNullableDateTime_ShouldMatchExpectedDateTime() + { + (DateTime? expected, string json)[] testCases = + [ + ( + new DateTime(2023, 10, 5, 14, 30, 0, DateTimeKind.Utc), + "\"2023-10-05T14:30:00.000Z\"" + ), + (null, "null"), + ]; + + foreach (var (expected, json) in testCases) + { + var dateTime = JsonUtils.Deserialize(json); + Assert.That(dateTime, Is.EqualTo(expected)); + } + } +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Core/Json/JsonAccessAttributeTests.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Core/Json/JsonAccessAttributeTests.cs new file mode 100644 index 000000000000..b480a4402785 --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Core/Json/JsonAccessAttributeTests.cs @@ -0,0 +1,160 @@ +using global::System.Text.Json.Serialization; +using NUnit.Framework; +using SeedNullable.Core; + +namespace SeedNullable.Test.Core.Json; + +[TestFixture] +public class JsonAccessAttributeTests +{ + private class MyClass + { + [JsonPropertyName("read_only_prop")] + [JsonAccess(JsonAccessType.ReadOnly)] + public string? ReadOnlyProp { get; set; } + + [JsonPropertyName("write_only_prop")] + [JsonAccess(JsonAccessType.WriteOnly)] + public string? WriteOnlyProp { get; set; } + + [JsonPropertyName("normal_prop")] + public string? NormalProp { get; set; } + + [JsonPropertyName("read_only_nullable_list")] + [JsonAccess(JsonAccessType.ReadOnly)] + public IEnumerable? ReadOnlyNullableList { get; set; } + + [JsonPropertyName("read_only_list")] + [JsonAccess(JsonAccessType.ReadOnly)] + public IEnumerable ReadOnlyList { get; set; } = []; + + [JsonPropertyName("write_only_nullable_list")] + [JsonAccess(JsonAccessType.WriteOnly)] + public IEnumerable? WriteOnlyNullableList { get; set; } + + [JsonPropertyName("write_only_list")] + [JsonAccess(JsonAccessType.WriteOnly)] + public IEnumerable WriteOnlyList { get; set; } = []; + + [JsonPropertyName("normal_list")] + public IEnumerable NormalList { get; set; } = []; + + [JsonPropertyName("normal_nullable_list")] + public IEnumerable? NullableNormalList { get; set; } + } + + [Test] + public void JsonAccessAttribute_ShouldWorkAsExpected() + { + const string json = """ + { + "read_only_prop": "read", + "write_only_prop": "write", + "normal_prop": "normal_prop", + "read_only_nullable_list": ["item1", "item2"], + "read_only_list": ["item3", "item4"], + "write_only_nullable_list": ["item5", "item6"], + "write_only_list": ["item7", "item8"], + "normal_list": ["normal1", "normal2"], + "normal_nullable_list": ["normal1", "normal2"] + } + """; + var obj = JsonUtils.Deserialize(json); + + Assert.Multiple(() => + { + // String properties + Assert.That(obj.ReadOnlyProp, Is.EqualTo("read")); + Assert.That(obj.WriteOnlyProp, Is.Null); + Assert.That(obj.NormalProp, Is.EqualTo("normal_prop")); + + // List properties - read only + var nullableReadOnlyList = obj.ReadOnlyNullableList?.ToArray(); + Assert.That(nullableReadOnlyList, Is.Not.Null); + Assert.That(nullableReadOnlyList, Has.Length.EqualTo(2)); + Assert.That(nullableReadOnlyList![0], Is.EqualTo("item1")); + Assert.That(nullableReadOnlyList![1], Is.EqualTo("item2")); + + var readOnlyList = obj.ReadOnlyList.ToArray(); + Assert.That(readOnlyList, Is.Not.Null); + Assert.That(readOnlyList, Has.Length.EqualTo(2)); + Assert.That(readOnlyList[0], Is.EqualTo("item3")); + Assert.That(readOnlyList[1], Is.EqualTo("item4")); + + // List properties - write only + Assert.That(obj.WriteOnlyNullableList, Is.Null); + Assert.That(obj.WriteOnlyList, Is.Not.Null); + Assert.That(obj.WriteOnlyList, Is.Empty); + + // Normal list property + var normalList = obj.NormalList.ToArray(); + Assert.That(normalList, Is.Not.Null); + Assert.That(normalList, Has.Length.EqualTo(2)); + Assert.That(normalList[0], Is.EqualTo("normal1")); + Assert.That(normalList[1], Is.EqualTo("normal2")); + }); + + // Set up values for serialization + obj.WriteOnlyProp = "write"; + obj.NormalProp = "new_value"; + obj.WriteOnlyNullableList = new List { "write1", "write2" }; + obj.WriteOnlyList = new List { "write3", "write4" }; + obj.NormalList = new List { "new_normal" }; + obj.NullableNormalList = new List { "new_normal" }; + + var serializedJson = JsonUtils.Serialize(obj); + const string expectedJson = """ + { + "write_only_prop": "write", + "normal_prop": "new_value", + "write_only_nullable_list": [ + "write1", + "write2" + ], + "write_only_list": [ + "write3", + "write4" + ], + "normal_list": [ + "new_normal" + ], + "normal_nullable_list": [ + "new_normal" + ] + } + """; + Assert.That(serializedJson, Is.EqualTo(expectedJson).IgnoreWhiteSpace); + } + + [Test] + public void JsonAccessAttribute_WithNullListsInJson_ShouldWorkAsExpected() + { + const string json = """ + { + "read_only_prop": "read", + "normal_prop": "normal_prop", + "read_only_nullable_list": null, + "read_only_list": [] + } + """; + var obj = JsonUtils.Deserialize(json); + + Assert.Multiple(() => + { + // Read-only nullable list should be null when JSON contains null + var nullableReadOnlyList = obj.ReadOnlyNullableList?.ToArray(); + Assert.That(nullableReadOnlyList, Is.Null); + + // Read-only non-nullable list should never be null, but empty when JSON contains null + var readOnlyList = obj.ReadOnlyList.ToArray(); // This should be initialized to an empty list by default + Assert.That(readOnlyList, Is.Not.Null); + Assert.That(readOnlyList, Is.Empty); + }); + + // Serialize and verify read-only lists are not included + var serializedJson = JsonUtils.Serialize(obj); + Assert.That(serializedJson, Does.Not.Contain("read_only_prop")); + Assert.That(serializedJson, Does.Not.Contain("read_only_nullable_list")); + Assert.That(serializedJson, Does.Not.Contain("read_only_list")); + } +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Core/Json/OneOfSerializerTests.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Core/Json/OneOfSerializerTests.cs new file mode 100644 index 000000000000..7226d57e5759 --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Core/Json/OneOfSerializerTests.cs @@ -0,0 +1,314 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using NUnit.Framework; +using OneOf; +using SeedNullable.Core; + +namespace SeedNullable.Test.Core.Json; + +[TestFixture] +[Parallelizable(ParallelScope.All)] +public class OneOfSerializerTests +{ + private class Foo + { + [JsonPropertyName("string_prop")] + public required string StringProp { get; set; } + } + + private class Bar + { + [JsonPropertyName("int_prop")] + public required int IntProp { get; set; } + } + + private static readonly OneOf OneOf1 = OneOf< + string, + int, + object, + Foo, + Bar + >.FromT2(new { }); + private const string OneOf1String = "{}"; + + private static readonly OneOf OneOf2 = OneOf< + string, + int, + object, + Foo, + Bar + >.FromT0("test"); + private const string OneOf2String = "\"test\""; + + private static readonly OneOf OneOf3 = OneOf< + string, + int, + object, + Foo, + Bar + >.FromT1(123); + private const string OneOf3String = "123"; + + private static readonly OneOf OneOf4 = OneOf< + string, + int, + object, + Foo, + Bar + >.FromT3(new Foo { StringProp = "test" }); + private const string OneOf4String = "{\"string_prop\": \"test\"}"; + + private static readonly OneOf OneOf5 = OneOf< + string, + int, + object, + Foo, + Bar + >.FromT4(new Bar { IntProp = 5 }); + private const string OneOf5String = "{\"int_prop\": 5}"; + + [Test] + public void Serialize_OneOfs_Should_Return_Expected_String() + { + (OneOf, string)[] testData = + [ + (OneOf1, OneOf1String), + (OneOf2, OneOf2String), + (OneOf3, OneOf3String), + (OneOf4, OneOf4String), + (OneOf5, OneOf5String), + ]; + Assert.Multiple(() => + { + foreach (var (oneOf, expected) in testData) + { + var result = JsonUtils.Serialize(oneOf); + Assert.That(result, Is.EqualTo(expected).IgnoreWhiteSpace); + } + }); + } + + [Test] + public void OneOfs_Should_Deserialize_From_String() + { + (OneOf, string)[] testData = + [ + (OneOf1, OneOf1String), + (OneOf2, OneOf2String), + (OneOf3, OneOf3String), + (OneOf4, OneOf4String), + (OneOf5, OneOf5String), + ]; + Assert.Multiple(() => + { + foreach (var (oneOf, json) in testData) + { + var result = JsonUtils.Deserialize>(json); + Assert.That(result.Index, Is.EqualTo(oneOf.Index)); + Assert.That(json, Is.EqualTo(JsonUtils.Serialize(result.Value)).IgnoreWhiteSpace); + } + }); + } + + private static readonly OneOf? NullableOneOf1 = null; + private const string NullableOneOf1String = "null"; + + private static readonly OneOf? NullableOneOf2 = OneOf< + string, + int, + object, + Foo, + Bar + >.FromT4(new Bar { IntProp = 5 }); + private const string NullableOneOf2String = "{\"int_prop\": 5}"; + + [Test] + public void Serialize_NullableOneOfs_Should_Return_Expected_String() + { + (OneOf?, string)[] testData = + [ + (NullableOneOf1, NullableOneOf1String), + (NullableOneOf2, NullableOneOf2String), + ]; + Assert.Multiple(() => + { + foreach (var (oneOf, expected) in testData) + { + var result = JsonUtils.Serialize(oneOf); + Assert.That(result, Is.EqualTo(expected).IgnoreWhiteSpace); + } + }); + } + + [Test] + public void NullableOneOfs_Should_Deserialize_From_String() + { + (OneOf?, string)[] testData = + [ + (NullableOneOf1, NullableOneOf1String), + (NullableOneOf2, NullableOneOf2String), + ]; + Assert.Multiple(() => + { + foreach (var (oneOf, json) in testData) + { + var result = JsonUtils.Deserialize?>(json); + Assert.That(result?.Index, Is.EqualTo(oneOf?.Index)); + Assert.That(json, Is.EqualTo(JsonUtils.Serialize(result?.Value)).IgnoreWhiteSpace); + } + }); + } + + private static readonly OneOf OneOfWithNullable1 = OneOf< + string, + int, + Foo? + >.FromT2(null); + private const string OneOfWithNullable1String = "null"; + + private static readonly OneOf OneOfWithNullable2 = OneOf< + string, + int, + Foo? + >.FromT2(new Foo { StringProp = "test" }); + private const string OneOfWithNullable2String = "{\"string_prop\": \"test\"}"; + + private static readonly OneOf OneOfWithNullable3 = OneOf< + string, + int, + Foo? + >.FromT0("test"); + private const string OneOfWithNullable3String = "\"test\""; + + [Test] + public void Serialize_OneOfWithNullables_Should_Return_Expected_String() + { + (OneOf, string)[] testData = + [ + (OneOfWithNullable1, OneOfWithNullable1String), + (OneOfWithNullable2, OneOfWithNullable2String), + (OneOfWithNullable3, OneOfWithNullable3String), + ]; + Assert.Multiple(() => + { + foreach (var (oneOf, expected) in testData) + { + var result = JsonUtils.Serialize(oneOf); + Assert.That(result, Is.EqualTo(expected).IgnoreWhiteSpace); + } + }); + } + + [Test] + public void OneOfWithNullables_Should_Deserialize_From_String() + { + (OneOf, string)[] testData = + [ + // (OneOfWithNullable1, OneOfWithNullable1String), // not possible with .NET's JSON serializer + (OneOfWithNullable2, OneOfWithNullable2String), + (OneOfWithNullable3, OneOfWithNullable3String), + ]; + Assert.Multiple(() => + { + foreach (var (oneOf, json) in testData) + { + var result = JsonUtils.Deserialize>(json); + Assert.That(result.Index, Is.EqualTo(oneOf.Index)); + Assert.That(json, Is.EqualTo(JsonUtils.Serialize(result.Value)).IgnoreWhiteSpace); + } + }); + } + + [Test] + public void Serialize_OneOfWithObjectLast_Should_Return_Expected_String() + { + var oneOfWithObjectLast = OneOf.FromT4( + new { random = "data" } + ); + const string oneOfWithObjectLastString = "{\"random\": \"data\"}"; + + var result = JsonUtils.Serialize(oneOfWithObjectLast); + Assert.That(result, Is.EqualTo(oneOfWithObjectLastString).IgnoreWhiteSpace); + } + + [Test] + public void OneOfWithObjectLast_Should_Deserialize_From_String() + { + const string oneOfWithObjectLastString = "{\"random\": \"data\"}"; + var result = JsonUtils.Deserialize>( + oneOfWithObjectLastString + ); + Assert.Multiple(() => + { + Assert.That(result.Index, Is.EqualTo(4)); + Assert.That(result.Value, Is.InstanceOf()); + Assert.That( + JsonUtils.Serialize(result.Value), + Is.EqualTo(oneOfWithObjectLastString).IgnoreWhiteSpace + ); + }); + } + + [Test] + public void Serialize_OneOfWithObjectNotLast_Should_Return_Expected_String() + { + var oneOfWithObjectNotLast = OneOf.FromT1( + new { random = "data" } + ); + const string oneOfWithObjectNotLastString = "{\"random\": \"data\"}"; + + var result = JsonUtils.Serialize(oneOfWithObjectNotLast); + Assert.That(result, Is.EqualTo(oneOfWithObjectNotLastString).IgnoreWhiteSpace); + } + + [Test] + public void OneOfWithObjectNotLast_Should_Deserialize_From_String() + { + const string oneOfWithObjectNotLastString = "{\"random\": \"data\"}"; + var result = JsonUtils.Deserialize>( + oneOfWithObjectNotLastString + ); + Assert.Multiple(() => + { + Assert.That(result.Index, Is.EqualTo(1)); + Assert.That(result.Value, Is.InstanceOf()); + Assert.That( + JsonUtils.Serialize(result.Value), + Is.EqualTo(oneOfWithObjectNotLastString).IgnoreWhiteSpace + ); + }); + } + + [Test] + public void Serialize_OneOfSingleType_Should_Return_Expected_String() + { + var oneOfSingle = OneOf.FromT0("single"); + const string oneOfSingleString = "\"single\""; + + var result = JsonUtils.Serialize(oneOfSingle); + Assert.That(result, Is.EqualTo(oneOfSingleString)); + } + + [Test] + public void OneOfSingleType_Should_Deserialize_From_String() + { + const string oneOfSingleString = "\"single\""; + var result = JsonUtils.Deserialize>(oneOfSingleString); + Assert.Multiple(() => + { + Assert.That(result.Index, Is.EqualTo(0)); + Assert.That(result.Value, Is.EqualTo("single")); + }); + } + + [Test] + public void Deserialize_InvalidData_Should_Throw_Exception() + { + const string invalidJson = "{\"invalid\": \"data\"}"; + + Assert.Throws(() => + { + JsonUtils.Deserialize>(invalidJson); + }); + } +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Core/Json/StringEnumSerializerTests.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Core/Json/StringEnumSerializerTests.cs new file mode 100644 index 000000000000..1e8ceff314d5 --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Core/Json/StringEnumSerializerTests.cs @@ -0,0 +1,138 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using NUnit.Framework; +using SeedNullable.Core; + +namespace SeedNullable.Test.Core.Json; + +[TestFixture] +[Parallelizable(ParallelScope.All)] +public class StringEnumSerializerTests +{ + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + + private static readonly DummyEnum KnownEnumValue2 = DummyEnum.KnownValue2; + private static readonly DummyEnum UnknownEnumValue = DummyEnum.FromCustom("unknown_value"); + + private static readonly string JsonWithKnownEnum2 = $$""" + { + "enum_property": "{{KnownEnumValue2}}" + } + """; + + private static readonly string JsonWithUnknownEnum = $$""" + { + "enum_property": "{{UnknownEnumValue}}" + } + """; + + [Test] + public void ShouldParseKnownEnumValue2() + { + var obj = JsonSerializer.Deserialize(JsonWithKnownEnum2, JsonOptions); + Assert.That(obj, Is.Not.Null); + Assert.That(obj.EnumProperty, Is.EqualTo(KnownEnumValue2)); + } + + [Test] + public void ShouldParseUnknownEnum() + { + var obj = JsonSerializer.Deserialize(JsonWithUnknownEnum, JsonOptions); + Assert.That(obj, Is.Not.Null); + Assert.That(obj.EnumProperty, Is.EqualTo(UnknownEnumValue)); + } + + [Test] + public void ShouldSerializeKnownEnumValue2() + { + var json = JsonSerializer.SerializeToElement( + new DummyObject { EnumProperty = KnownEnumValue2 }, + JsonOptions + ); + TestContext.Out.WriteLine("Serialized JSON: \n" + json); + var enumString = json.GetProperty("enum_property").GetString(); + Assert.That(enumString, Is.Not.Null); + Assert.That(enumString, Is.EqualTo(KnownEnumValue2)); + } + + [Test] + public void ShouldSerializeUnknownEnum() + { + var json = JsonSerializer.SerializeToElement( + new DummyObject { EnumProperty = UnknownEnumValue }, + JsonOptions + ); + TestContext.Out.WriteLine("Serialized JSON: \n" + json); + var enumString = json.GetProperty("enum_property").GetString(); + Assert.That(enumString, Is.Not.Null); + Assert.That(enumString, Is.EqualTo(UnknownEnumValue)); + } +} + +public class DummyObject +{ + [JsonPropertyName("enum_property")] + public DummyEnum EnumProperty { get; set; } +} + +[JsonConverter(typeof(StringEnumSerializer))] +public readonly record struct DummyEnum : IStringEnum +{ + public DummyEnum(string value) + { + Value = value; + } + + /// + /// The string value of the enum. + /// + public string Value { get; } + + public static readonly DummyEnum KnownValue1 = FromCustom(Values.KnownValue1); + + public static readonly DummyEnum KnownValue2 = FromCustom(Values.KnownValue2); + + /// + /// Constant strings for enum values + /// + public static class Values + { + public const string KnownValue1 = "known_value1"; + + public const string KnownValue2 = "known_value2"; + } + + /// + /// Create a string enum with the given value. + /// + public static DummyEnum FromCustom(string value) + { + return new DummyEnum(value); + } + + /// + /// Returns the string value of the enum. + /// + public override string ToString() + { + return Value; + } + + public bool Equals(string? other) + { + return Value.Equals(other); + } + + public override int GetHashCode() + { + return Value.GetHashCode(); + } + + public static explicit operator string(DummyEnum value) => value.Value; + + public static explicit operator DummyEnum(string value) => new(value); + + public static bool operator ==(DummyEnum value1, string value2) => value1.Value.Equals(value2); + + public static bool operator !=(DummyEnum value1, string value2) => !value1.Value.Equals(value2); +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Core/QueryStringConverterTests.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Core/QueryStringConverterTests.cs new file mode 100644 index 000000000000..cb46d2dda5e8 --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Core/QueryStringConverterTests.cs @@ -0,0 +1,124 @@ +using NUnit.Framework; +using SeedNullable.Core; + +namespace SeedNullable.Test.Core; + +[TestFixture] +public class QueryStringConverterTests +{ + [Test] + public void ToQueryStringCollection_Form() + { + var obj = new + { + Name = "John", + Age = 30, + Address = new + { + Street = "123 Main St", + City = "Anytown", + Coordinates = new[] { 39.781721f, -89.650148f }, + }, + Tags = new[] { "Developer", "Blogger" }, + }; + var result = QueryStringConverter.ToForm(obj); + var expected = new List> + { + new("Name", "John"), + new("Age", "30"), + new("Address[Street]", "123 Main St"), + new("Address[City]", "Anytown"), + new("Address[Coordinates]", "39.78172,-89.65015"), + new("Tags", "Developer,Blogger"), + }; + Assert.That(result, Is.EqualTo(expected)); + } + + [Test] + public void ToQueryStringCollection_ExplodedForm() + { + var obj = new + { + Name = "John", + Age = 30, + Address = new + { + Street = "123 Main St", + City = "Anytown", + Coordinates = new[] { 39.781721f, -89.650148f }, + }, + Tags = new[] { "Developer", "Blogger" }, + }; + var result = QueryStringConverter.ToExplodedForm(obj); + var expected = new List> + { + new("Name", "John"), + new("Age", "30"), + new("Address[Street]", "123 Main St"), + new("Address[City]", "Anytown"), + new("Address[Coordinates]", "39.78172"), + new("Address[Coordinates]", "-89.65015"), + new("Tags", "Developer"), + new("Tags", "Blogger"), + }; + Assert.That(result, Is.EqualTo(expected)); + } + + [Test] + public void ToQueryStringCollection_DeepObject() + { + var obj = new + { + Name = "John", + Age = 30, + Address = new + { + Street = "123 Main St", + City = "Anytown", + Coordinates = new[] { 39.781721f, -89.650148f }, + }, + Tags = new[] { "Developer", "Blogger" }, + }; + var result = QueryStringConverter.ToDeepObject(obj); + var expected = new List> + { + new("Name", "John"), + new("Age", "30"), + new("Address[Street]", "123 Main St"), + new("Address[City]", "Anytown"), + new("Address[Coordinates][0]", "39.78172"), + new("Address[Coordinates][1]", "-89.65015"), + new("Tags[0]", "Developer"), + new("Tags[1]", "Blogger"), + }; + Assert.That(result, Is.EqualTo(expected)); + } + + [Test] + public void ToQueryStringCollection_OnString_ThrowsException() + { + var exception = Assert.Throws(() => + QueryStringConverter.ToForm("invalid") + ); + Assert.That( + exception.Message, + Is.EqualTo( + "Only objects can be converted to query string collections. Given type is String." + ) + ); + } + + [Test] + public void ToQueryStringCollection_OnArray_ThrowsException() + { + var exception = Assert.Throws(() => + QueryStringConverter.ToForm(Array.Empty()) + ); + Assert.That( + exception.Message, + Is.EqualTo( + "Only objects can be converted to query string collections. Given type is Array." + ) + ); + } +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Core/RawClientTests/AdditionalHeadersTests.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Core/RawClientTests/AdditionalHeadersTests.cs new file mode 100644 index 000000000000..53d8432db180 --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Core/RawClientTests/AdditionalHeadersTests.cs @@ -0,0 +1,138 @@ +using NUnit.Framework; +using SeedNullable.Core; +using WireMock.Server; +using SystemTask = global::System.Threading.Tasks.Task; +using WireMockRequest = WireMock.RequestBuilders.Request; +using WireMockResponse = WireMock.ResponseBuilders.Response; + +// ReSharper disable NullableWarningSuppressionIsUsed + +namespace SeedNullable.Test.Core.RawClientTests; + +[TestFixture] +[Parallelizable(ParallelScope.Self)] +public class AdditionalHeadersTests +{ + private WireMockServer _server; + private HttpClient _httpClient; + private RawClient _rawClient; + private string _baseUrl; + + [SetUp] + public void SetUp() + { + _server = WireMockServer.Start(); + _baseUrl = _server.Url ?? ""; + _httpClient = new HttpClient { BaseAddress = new Uri(_baseUrl) }; + _rawClient = new RawClient( + new ClientOptions + { + HttpClient = _httpClient, + Headers = new Headers( + new Dictionary + { + ["a"] = "client_headers", + ["b"] = "client_headers", + ["c"] = "client_headers", + ["d"] = "client_headers", + ["e"] = "client_headers", + ["f"] = "client_headers", + ["client_multiple"] = "client_headers", + } + ), + AdditionalHeaders = new List> + { + new("b", "client_additional_headers"), + new("c", "client_additional_headers"), + new("d", "client_additional_headers"), + new("e", null), + new("client_multiple", "client_additional_headers1"), + new("client_multiple", "client_additional_headers2"), + }, + } + ); + } + + [Test] + public async SystemTask SendRequestAsync_AdditionalHeaderParameters() + { + _server + .Given(WireMockRequest.Create().WithPath("/test").UsingGet()) + .RespondWith(WireMockResponse.Create().WithStatusCode(200).WithBody("Success")); + + var request = new JsonRequest + { + BaseUrl = _baseUrl, + Method = HttpMethod.Get, + Path = "/test", + Headers = new Headers( + new Dictionary + { + ["c"] = "request_headers", + ["d"] = "request_headers", + ["request_multiple"] = "request_headers", + } + ), + Options = new RequestOptions + { + AdditionalHeaders = new List> + { + new("d", "request_additional_headers"), + new("f", null), + new("request_multiple", "request_additional_headers1"), + new("request_multiple", "request_additional_headers2"), + }, + }, + }; + + var response = await _rawClient.SendRequestAsync(request); + Assert.That(response.StatusCode, Is.EqualTo(200)); + + var content = await response.Raw.Content.ReadAsStringAsync(); + Assert.Multiple(() => + { + Assert.That(content, Is.EqualTo("Success")); + Assert.That(_server.LogEntries.Count, Is.EqualTo(1)); + var headers = + _server.LogEntries[0].RequestMessage.Headers ?? throw new global::System.Exception( + "Headers are null" + ); + + Assert.That(headers, Contains.Key("client_multiple")); + Assert.That(headers!["client_multiple"][0], Does.Contain("client_additional_headers1")); + Assert.That(headers["client_multiple"][0], Does.Contain("client_additional_headers2")); + + Assert.That(headers, Contains.Key("request_multiple")); + Assert.That( + headers["request_multiple"][0], + Does.Contain("request_additional_headers1") + ); + Assert.That( + headers["request_multiple"][0], + Does.Contain("request_additional_headers2") + ); + + Assert.That(headers, Contains.Key("a")); + Assert.That(headers["a"][0], Does.Contain("client_headers")); + + Assert.That(headers, Contains.Key("b")); + Assert.That(headers["b"][0], Does.Contain("client_additional_headers")); + + Assert.That(headers, Contains.Key("c")); + Assert.That(headers["c"][0], Does.Contain("request_headers")); + + Assert.That(headers, Contains.Key("d")); + Assert.That(headers["d"][0], Does.Contain("request_additional_headers")); + + Assert.That(headers, Does.Not.ContainKey("e")); + Assert.That(headers, Does.Not.ContainKey("f")); + }); + } + + [TearDown] + public void TearDown() + { + _server.Dispose(); + _httpClient.Dispose(); + } +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Core/RawClientTests/AdditionalParametersTests.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Core/RawClientTests/AdditionalParametersTests.cs new file mode 100644 index 000000000000..61f78fe7db06 --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Core/RawClientTests/AdditionalParametersTests.cs @@ -0,0 +1,300 @@ +using NUnit.Framework; +using SeedNullable.Core; +using WireMock.Matchers; +using WireMock.Server; +using SystemTask = global::System.Threading.Tasks.Task; +using WireMockRequest = WireMock.RequestBuilders.Request; +using WireMockResponse = WireMock.ResponseBuilders.Response; + +namespace SeedNullable.Test.Core.RawClientTests; + +[TestFixture] +[Parallelizable(ParallelScope.Self)] +public class AdditionalParametersTests +{ + private WireMockServer _server; + private HttpClient _httpClient; + private RawClient _rawClient; + private string _baseUrl; + + [SetUp] + public void SetUp() + { + _server = WireMockServer.Start(); + _baseUrl = _server.Url ?? ""; + _httpClient = new HttpClient { BaseAddress = new Uri(_baseUrl) }; + _rawClient = new RawClient(new ClientOptions { HttpClient = _httpClient }); + } + + [Test] + public async SystemTask SendRequestAsync_AdditionalQueryParameters() + { + _server + .Given(WireMockRequest.Create().WithPath("/test").WithParam("foo", "bar").UsingGet()) + .RespondWith(WireMockResponse.Create().WithStatusCode(200).WithBody("Success")); + + var request = new JsonRequest() + { + BaseUrl = _baseUrl, + Method = HttpMethod.Get, + Path = "/test", + Options = new RequestOptions + { + AdditionalQueryParameters = new List> + { + new("foo", "bar"), + }, + }, + }; + + var response = await _rawClient.SendRequestAsync(request); + Assert.That(response.StatusCode, Is.EqualTo(200)); + + var content = await response.Raw.Content.ReadAsStringAsync(); + Assert.That(content, Is.EqualTo("Success")); + + Assert.That(_server.LogEntries.Count, Is.EqualTo(1)); + } + + [Test] + public async SystemTask SendRequestAsync_AdditionalQueryParameters_Override() + { + _server + .Given(WireMockRequest.Create().WithPath("/test").WithParam("foo", "null").UsingGet()) + .RespondWith(WireMockResponse.Create().WithStatusCode(200).WithBody("Success")); + + var request = new JsonRequest() + { + BaseUrl = _baseUrl, + Method = HttpMethod.Get, + Path = "/test", + Query = new Dictionary { { "foo", "bar" } }, + Options = new RequestOptions + { + AdditionalQueryParameters = new List> + { + new("foo", "null"), + }, + }, + }; + + var response = await _rawClient.SendRequestAsync(request); + Assert.That(response.StatusCode, Is.EqualTo(200)); + + var content = await response.Raw.Content.ReadAsStringAsync(); + Assert.That(content, Is.EqualTo("Success")); + + Assert.That(_server.LogEntries.Count, Is.EqualTo(1)); + } + + [Test] + public async SystemTask SendRequestAsync_AdditionalQueryParameters_Merge() + { + _server + .Given(WireMockRequest.Create().WithPath("/test").UsingGet()) + .RespondWith(WireMockResponse.Create().WithStatusCode(200).WithBody("Success")); + + var request = new JsonRequest() + { + BaseUrl = _baseUrl, + Method = HttpMethod.Get, + Path = "/test", + Query = new Dictionary { { "foo", "baz" } }, + Options = new RequestOptions + { + AdditionalQueryParameters = new List> + { + new("foo", "one"), + new("foo", "two"), + }, + }, + }; + + var response = await _rawClient.SendRequestAsync(request); + Assert.That(response.StatusCode, Is.EqualTo(200)); + + var content = await response.Raw.Content.ReadAsStringAsync(); + Assert.That(content, Is.EqualTo("Success")); + + Assert.That(_server.LogEntries.Count, Is.EqualTo(1)); + + var requestUrl = _server.LogEntries.First().RequestMessage.Url; + Assert.That(requestUrl, Does.Contain("foo=one")); + Assert.That(requestUrl, Does.Contain("foo=two")); + Assert.That(requestUrl, Does.Not.Contain("foo=baz")); + } + + [Test] + public async SystemTask SendRequestAsync_AdditionalBodyProperties() + { + string expectedBody = "{\n \"foo\": \"bar\",\n \"baz\": \"qux\"\n}"; + _server + .Given( + WireMockRequest + .Create() + .WithPath("/test") + .UsingPost() + .WithBody(new JsonMatcher(expectedBody)) + ) + .RespondWith(WireMockResponse.Create().WithStatusCode(200).WithBody("Success")); + + var request = new JsonRequest + { + BaseUrl = _baseUrl, + Method = HttpMethod.Post, + Path = "/test", + Body = new Dictionary { { "foo", "bar" } }, + Options = new RequestOptions + { + AdditionalBodyProperties = new Dictionary { { "baz", "qux" } }, + }, + }; + + var response = await _rawClient.SendRequestAsync(request); + Assert.That(response.StatusCode, Is.EqualTo(200)); + + var content = await response.Raw.Content.ReadAsStringAsync(); + Assert.That(content, Is.EqualTo("Success")); + + Assert.That(_server.LogEntries.Count, Is.EqualTo(1)); + } + + [Test] + public async SystemTask SendRequestAsync_AdditionalBodyProperties_Override() + { + string expectedBody = "{\n \"foo\": null\n}"; + _server + .Given( + WireMockRequest + .Create() + .WithPath("/test") + .UsingPost() + .WithBody(new JsonMatcher(expectedBody)) + ) + .RespondWith(WireMockResponse.Create().WithStatusCode(200).WithBody("Success")); + + var request = new JsonRequest + { + BaseUrl = _baseUrl, + Method = HttpMethod.Post, + Path = "/test", + Body = new Dictionary { { "foo", "bar" } }, + Options = new RequestOptions + { + AdditionalBodyProperties = new Dictionary { { "foo", null } }, + }, + }; + + var response = await _rawClient.SendRequestAsync(request); + Assert.That(response.StatusCode, Is.EqualTo(200)); + + var content = await response.Raw.Content.ReadAsStringAsync(); + Assert.That(content, Is.EqualTo("Success")); + + Assert.That(_server.LogEntries.Count, Is.EqualTo(1)); + } + + [Test] + public async SystemTask SendRequestAsync_AdditionalBodyProperties_DeepMerge() + { + const string expectedBody = """ + { + "foo": { + "inner1": "original", + "inner2": "overridden", + "inner3": { + "deepProp1": "deep-override", + "deepProp2": "original", + "deepProp3": null, + "deepProp4": "new-value" + } + }, + "bar": "new-value", + "baz": ["new","value"] + } + """; + + _server + .Given( + WireMockRequest + .Create() + .WithPath("/test-deep-merge") + .UsingPost() + .WithBody(new JsonMatcher(expectedBody)) + ) + .RespondWith(WireMockResponse.Create().WithStatusCode(200).WithBody("Success")); + + var request = new JsonRequest + { + BaseUrl = _baseUrl, + Method = HttpMethod.Post, + Path = "/test-deep-merge", + Body = new Dictionary + { + { + "foo", + new Dictionary + { + { "inner1", "original" }, + { "inner2", "original" }, + { + "inner3", + new Dictionary + { + { "deepProp1", "deep-original" }, + { "deepProp2", "original" }, + { "deepProp3", "" }, + } + }, + } + }, + { + "baz", + new List { "original" } + }, + }, + Options = new RequestOptions + { + AdditionalBodyProperties = new Dictionary + { + { + "foo", + new Dictionary + { + { "inner2", "overridden" }, + { + "inner3", + new Dictionary + { + { "deepProp1", "deep-override" }, + { "deepProp3", null }, + { "deepProp4", "new-value" }, + } + }, + } + }, + { "bar", "new-value" }, + { + "baz", + new List { "new", "value" } + }, + }, + }, + }; + + var response = await _rawClient.SendRequestAsync(request); + Assert.That(response.StatusCode, Is.EqualTo(200)); + + var content = await response.Raw.Content.ReadAsStringAsync(); + Assert.That(content, Is.EqualTo("Success")); + + Assert.That(_server.LogEntries.Count, Is.EqualTo(1)); + } + + [TearDown] + public void TearDown() + { + _server.Dispose(); + _httpClient.Dispose(); + } +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Core/RawClientTests/MultipartFormTests.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Core/RawClientTests/MultipartFormTests.cs new file mode 100644 index 000000000000..6811399ebad7 --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Core/RawClientTests/MultipartFormTests.cs @@ -0,0 +1,1120 @@ +using global::System.Net.Http; +using global::System.Text; +using global::System.Text.Json.Serialization; +using NUnit.Framework; +using SeedNullable.Core; +using SystemTask = global::System.Threading.Tasks.Task; + +namespace SeedNullable.Test.Core.RawClientTests; + +[TestFixture] +[Parallelizable(ParallelScope.Self)] +public class MultipartFormTests +{ + private static SimpleObject _simpleObject = new(); + + private static string _simpleFormEncoded = + "meta=data&Date=2023-10-01&Time=12:00:00&Duration=01:00:00&Id=1a1bb98f-47c6-407b-9481-78476affe52a&IsActive=true&Count=42&Initial=A&Values=data,2023-10-01,12:00:00,01:00:00,1a1bb98f-47c6-407b-9481-78476affe52a,true,42,A"; + + private static string _simpleExplodedFormEncoded = + "meta=data&Date=2023-10-01&Time=12:00:00&Duration=01:00:00&Id=1a1bb98f-47c6-407b-9481-78476affe52a&IsActive=true&Count=42&Initial=A&Values=data&Values=2023-10-01&Values=12:00:00&Values=01:00:00&Values=1a1bb98f-47c6-407b-9481-78476affe52a&Values=true&Values=42&Values=A"; + + private static ComplexObject _complexObject = new(); + + private static string _complexJson = """ + { + "meta": "data", + "Nested": { + "foo": "value" + }, + "NestedDictionary": { + "key": { + "foo": "value" + } + }, + "ListOfObjects": [ + { + "foo": "value" + }, + { + "foo": "value2" + } + ], + "Date": "2023-10-01", + "Time": "12:00:00", + "Duration": "01:00:00", + "Id": "1a1bb98f-47c6-407b-9481-78476affe52a", + "IsActive": true, + "Count": 42, + "Initial": "A" + } + """; + + [Test] + public async SystemTask ShouldAddStringPart() + { + const string partInput = "string content"; + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddStringPart("string", partInput); + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: text/plain + Content-Disposition: form-data; name=string + + {partInput} + --{boundary}-- + """; + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddStringParts() + { + const string partInput = "string content"; + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddStringParts("strings", [partInput, partInput]); + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: text/plain + Content-Disposition: form-data; name=strings + + {partInput} + --{boundary} + Content-Type: text/plain + Content-Disposition: form-data; name=strings + + {partInput} + --{boundary}-- + """; + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask GivenNull_ShouldNotAddStringPart() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddStringPart("string", null); + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + --{boundary}-- + """; + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddStringParts_WithNullsInList() + { + const string partInput = "string content"; + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddStringParts("strings", [partInput, null, partInput]); + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: text/plain + Content-Disposition: form-data; name=strings + + {partInput} + --{boundary} + Content-Type: text/plain + Content-Disposition: form-data; name=strings + + {partInput} + --{boundary}-- + """; + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddStringPart_WithContentType() + { + const string partInput = "string content"; + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddStringPart("string", partInput, "text/xml"); + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: text/xml + Content-Disposition: form-data; name=string + + {partInput} + --{boundary}-- + """; + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddStringPart_WithContentTypeAndCharset() + { + const string partInput = "string content"; + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddStringPart("string", partInput, "text/xml; charset=utf-8"); + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: text/xml; charset=utf-8 + Content-Disposition: form-data; name=string + + {partInput} + --{boundary}-- + """; + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddStringParts_WithContentType() + { + const string partInput = "string content"; + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddStringParts("strings", [partInput, partInput], "text/xml"); + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: text/xml + Content-Disposition: form-data; name=strings + + {partInput} + --{boundary} + Content-Type: text/xml + Content-Disposition: form-data; name=strings + + {partInput} + --{boundary}-- + """; + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddStringParts_WithContentTypeAndCharset() + { + const string partInput = "string content"; + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddStringParts( + "strings", + [partInput, partInput], + "text/xml; charset=utf-8" + ); + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: text/xml; charset=utf-8 + Content-Disposition: form-data; name=strings + + {partInput} + --{boundary} + Content-Type: text/xml; charset=utf-8 + Content-Disposition: form-data; name=strings + + {partInput} + --{boundary}-- + """; + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddFileParameter_WithFileName() + { + var (partInput, partExpectedString) = GetFileParameterTestData(); + var file = new FileParameter { Stream = partInput, FileName = "test.txt" }; + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddFileParameterPart("file", file); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/octet-stream + Content-Disposition: form-data; name=file; filename=test.txt; filename*=utf-8''test.txt + + {partExpectedString} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddFileParameter_WithoutFileName() + { + var (partInput, partExpectedString) = GetFileParameterTestData(); + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddFileParameterPart("file", partInput); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/octet-stream + Content-Disposition: form-data; name=file + + {partExpectedString} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddFileParameter_WithContentType() + { + var (partInput, partExpectedString) = GetFileParameterTestData(); + var file = new FileParameter + { + Stream = partInput, + FileName = "test.txt", + ContentType = "text/plain", + }; + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddFileParameterPart("file", file, "ignored-fallback-content-type"); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: text/plain + Content-Disposition: form-data; name=file; filename=test.txt; filename*=utf-8''test.txt + + {partExpectedString} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddFileParameter_WithContentTypeAndCharset() + { + var (partInput, partExpectedString) = GetFileParameterTestData(); + var file = new FileParameter + { + Stream = partInput, + FileName = "test.txt", + ContentType = "text/plain; charset=utf-8", + }; + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddFileParameterPart("file", file, "ignored-fallback-content-type"); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: text/plain; charset=utf-8 + Content-Disposition: form-data; name=file; filename=test.txt; filename*=utf-8''test.txt + + {partExpectedString} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddFileParameter_WithFallbackContentType() + { + var (partInput, partExpectedString) = GetFileParameterTestData(); + var file = new FileParameter { Stream = partInput, FileName = "test.txt" }; + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddFileParameterPart("file", file, "text/plain"); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: text/plain + Content-Disposition: form-data; name=file; filename=test.txt; filename*=utf-8''test.txt + + {partExpectedString} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddFileParameter_WithFallbackContentTypeAndCharset() + { + var (partInput, partExpectedString) = GetFileParameterTestData(); + var file = new FileParameter { Stream = partInput, FileName = "test.txt" }; + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddFileParameterPart("file", file, "text/plain; charset=utf-8"); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: text/plain; charset=utf-8 + Content-Disposition: form-data; name=file; filename=test.txt; filename*=utf-8''test.txt + + {partExpectedString} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddFileParameters() + { + var (partInput1, partExpectedString1) = GetFileParameterTestData(); + var (partInput2, partExpectedString2) = GetFileParameterTestData(); + var file1 = new FileParameter { Stream = partInput1, FileName = "test1.txt" }; + var file2 = new FileParameter { Stream = partInput2, FileName = "test2.txt" }; + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddFileParameterParts("file", [file1, file2]); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/octet-stream + Content-Disposition: form-data; name=file; filename=test1.txt; filename*=utf-8''test1.txt + + {partExpectedString1} + --{boundary} + Content-Type: application/octet-stream + Content-Disposition: form-data; name=file; filename=test2.txt; filename*=utf-8''test2.txt + + {partExpectedString2} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddFileParameters_WithNullsInList() + { + var (partInput1, partExpectedString1) = GetFileParameterTestData(); + var (partInput2, partExpectedString2) = GetFileParameterTestData(); + var file1 = new FileParameter { Stream = partInput1, FileName = "test1.txt" }; + var file2 = new FileParameter { Stream = partInput2, FileName = "test2.txt" }; + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddFileParameterParts("file", [file1, null, file2]); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/octet-stream + Content-Disposition: form-data; name=file; filename=test1.txt; filename*=utf-8''test1.txt + + {partExpectedString1} + --{boundary} + Content-Type: application/octet-stream + Content-Disposition: form-data; name=file; filename=test2.txt; filename*=utf-8''test2.txt + + {partExpectedString2} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask GivenNull_ShouldNotAddFileParameter() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddFileParameterPart("file", null); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddJsonPart_WithComplexObject() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddJsonPart("object", _complexObject); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/json + Content-Disposition: form-data; name=object + + {_complexJson} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddJsonPart_WithComplexObjectList() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddJsonParts("objects", [_complexObject, _complexObject]); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/json + Content-Disposition: form-data; name=objects + + {_complexJson} + --{boundary} + Content-Type: application/json + Content-Disposition: form-data; name=objects + + {_complexJson} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask GivenNull_ShouldNotAddJsonPart() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddJsonPart("object", null); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddJsonParts_WithNullsInList() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddJsonParts("objects", [_complexObject, null]); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/json + Content-Disposition: form-data; name=objects + + {_complexJson} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddJsonParts_WithContentType() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddJsonParts("objects", [new { }], "application/json-patch+json"); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $$""" + --{{boundary}} + Content-Type: application/json-patch+json + Content-Disposition: form-data; name=objects + + {} + --{{boundary}}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddFormEncodedParts_WithSimpleObject() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddFormEncodedPart("object", _simpleObject); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/x-www-form-urlencoded + Content-Disposition: form-data; name=object + + {EscapeFormEncodedString(_simpleFormEncoded)} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddFormEncodedParts_WithSimpleObjectList() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddFormEncodedParts("objects", [_simpleObject, _simpleObject]); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/x-www-form-urlencoded + Content-Disposition: form-data; name=objects + + {EscapeFormEncodedString(_simpleFormEncoded)} + --{boundary} + Content-Type: application/x-www-form-urlencoded + Content-Disposition: form-data; name=objects + + {EscapeFormEncodedString(_simpleFormEncoded)} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldNotAddFormEncodedParts_WithNull() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddFormEncodedParts("object", null); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldNotAddFormEncodedParts_WithNullsInList() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddFormEncodedParts("objects", [_simpleObject, null]); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/x-www-form-urlencoded + Content-Disposition: form-data; name=objects + + {EscapeFormEncodedString(_simpleFormEncoded)} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddFormEncodedPart_WithContentType() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddFormEncodedPart( + "objects", + new { foo = "bar" }, + "application/x-www-form-urlencoded" + ); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/x-www-form-urlencoded + Content-Disposition: form-data; name=objects + + foo=bar + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddFormEncodedPart_WithContentTypeAndCharset() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddFormEncodedPart( + "objects", + new { foo = "bar" }, + "application/x-www-form-urlencoded; charset=utf-8" + ); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/x-www-form-urlencoded; charset=utf-8 + Content-Disposition: form-data; name=objects + + foo=bar + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddFormEncodedParts_WithContentType() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddFormEncodedParts( + "objects", + [new { foo = "bar" }], + "application/x-www-form-urlencoded" + ); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/x-www-form-urlencoded + Content-Disposition: form-data; name=objects + + foo=bar + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddFormEncodedParts_WithContentTypeAndCharset() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddFormEncodedParts( + "objects", + [new { foo = "bar" }], + "application/x-www-form-urlencoded; charset=utf-8" + ); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/x-www-form-urlencoded; charset=utf-8 + Content-Disposition: form-data; name=objects + + foo=bar + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddExplodedFormEncodedParts_WithSimpleObject() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddExplodedFormEncodedPart("object", _simpleObject); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/x-www-form-urlencoded + Content-Disposition: form-data; name=object + + {EscapeFormEncodedString(_simpleExplodedFormEncoded)} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddExplodedFormEncodedParts_WithSimpleObjectList() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddExplodedFormEncodedParts("objects", [_simpleObject, _simpleObject]); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/x-www-form-urlencoded + Content-Disposition: form-data; name=objects + + {EscapeFormEncodedString(_simpleExplodedFormEncoded)} + --{boundary} + Content-Type: application/x-www-form-urlencoded + Content-Disposition: form-data; name=objects + + {EscapeFormEncodedString(_simpleExplodedFormEncoded)} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldNotAddExplodedFormEncodedParts_WithNull() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddExplodedFormEncodedPart("object", null); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldNotAddExplodedFormEncodedParts_WithNullsInList() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddExplodedFormEncodedParts("objects", [_simpleObject, null]); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/x-www-form-urlencoded + Content-Disposition: form-data; name=objects + + {EscapeFormEncodedString(_simpleExplodedFormEncoded)} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddExplodedFormEncodedPart_WithContentType() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddExplodedFormEncodedPart( + "objects", + new { foo = "bar" }, + "application/x-www-form-urlencoded" + ); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/x-www-form-urlencoded + Content-Disposition: form-data; name=objects + + foo=bar + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddExplodedFormEncodedPart_WithContentTypeAndCharset() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddExplodedFormEncodedPart( + "objects", + new { foo = "bar" }, + "application/x-www-form-urlencoded; charset=utf-8" + ); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/x-www-form-urlencoded; charset=utf-8 + Content-Disposition: form-data; name=objects + + foo=bar + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddExplodedFormEncodedParts_WithContentType() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddExplodedFormEncodedParts( + "objects", + [new { foo = "bar" }], + "application/x-www-form-urlencoded" + ); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/x-www-form-urlencoded + Content-Disposition: form-data; name=objects + + foo=bar + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddExplodedFormEncodedParts_WithContentTypeAndCharset() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddExplodedFormEncodedParts( + "objects", + [new { foo = "bar" }], + "application/x-www-form-urlencoded; charset=utf-8" + ); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/x-www-form-urlencoded; charset=utf-8 + Content-Disposition: form-data; name=objects + + foo=bar + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + private static string EscapeFormEncodedString(string input) + { + return string.Join( + "&", + input + .Split('&') + .Select(x => x.Split('=')) + .Select(x => $"{Uri.EscapeDataString(x[0])}={Uri.EscapeDataString(x[1])}") + ); + } + + private static string GetBoundary(MultipartFormDataContent content) + { + return content + .Headers.ContentType?.Parameters.Single(p => + p.Name.Equals("boundary", StringComparison.OrdinalIgnoreCase) + ) + .Value?.Trim('"') ?? throw new global::System.Exception("Boundary not found"); + } + + private static SeedNullable.Core.MultipartFormRequest CreateMultipartFormRequest() + { + return new SeedNullable.Core.MultipartFormRequest + { + BaseUrl = "https://localhost", + Method = HttpMethod.Post, + Path = "", + }; + } + + private static (Stream partInput, string partExpectedString) GetFileParameterTestData() + { + const string partExpectedString = "file content"; + var partInput = new MemoryStream(Encoding.Default.GetBytes(partExpectedString)); + return (partInput, partExpectedString); + } + + private class SimpleObject + { + [JsonPropertyName("meta")] + public string Meta { get; set; } = "data"; + public DateOnly Date { get; set; } = DateOnly.Parse("2023-10-01"); + public TimeOnly Time { get; set; } = TimeOnly.Parse("12:00:00"); + public TimeSpan Duration { get; set; } = TimeSpan.FromHours(1); + public Guid Id { get; set; } = Guid.Parse("1a1bb98f-47c6-407b-9481-78476affe52a"); + public bool IsActive { get; set; } = true; + public int Count { get; set; } = 42; + public char Initial { get; set; } = 'A'; + public IEnumerable Values { get; set; } = + [ + "data", + DateOnly.Parse("2023-10-01"), + TimeOnly.Parse("12:00:00"), + TimeSpan.FromHours(1), + Guid.Parse("1a1bb98f-47c6-407b-9481-78476affe52a"), + true, + 42, + 'A', + ]; + } + + private class ComplexObject + { + [JsonPropertyName("meta")] + public string Meta { get; set; } = "data"; + + public object Nested { get; set; } = new { foo = "value" }; + + public Dictionary NestedDictionary { get; set; } = + new() { { "key", new { foo = "value" } } }; + + public IEnumerable ListOfObjects { get; set; } = + new List { new { foo = "value" }, new { foo = "value2" } }; + + public DateOnly Date { get; set; } = DateOnly.Parse("2023-10-01"); + public TimeOnly Time { get; set; } = TimeOnly.Parse("12:00:00"); + public TimeSpan Duration { get; set; } = TimeSpan.FromHours(1); + public Guid Id { get; set; } = Guid.Parse("1a1bb98f-47c6-407b-9481-78476affe52a"); + public bool IsActive { get; set; } = true; + public int Count { get; set; } = 42; + public char Initial { get; set; } = 'A'; + } +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Core/RawClientTests/QueryParameterTests.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Core/RawClientTests/QueryParameterTests.cs new file mode 100644 index 000000000000..730b11e10833 --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Core/RawClientTests/QueryParameterTests.cs @@ -0,0 +1,64 @@ +using NUnit.Framework; +using SeedNullable.Core; +using WireMock.Matchers; +using WireMock.Server; +using SystemTask = global::System.Threading.Tasks.Task; +using WireMockRequest = WireMock.RequestBuilders.Request; +using WireMockResponse = WireMock.ResponseBuilders.Response; + +namespace SeedNullable.Test.Core.RawClientTests; + +[TestFixture] +[Parallelizable(ParallelScope.Self)] +public class QueryParameterTests +{ + private WireMockServer _server; + private HttpClient _httpClient; + private RawClient _rawClient; + private string _baseUrl; + + [SetUp] + public void SetUp() + { + _server = WireMockServer.Start(); + _baseUrl = _server.Url ?? ""; + _httpClient = new HttpClient { BaseAddress = new Uri(_baseUrl) }; + _rawClient = new RawClient(new ClientOptions { HttpClient = _httpClient }); + } + + [Test] + public async SystemTask CreateRequest_QueryParametersEscaping() + { + _server + .Given(WireMockRequest.Create().WithPath("/test").WithParam("foo", "bar").UsingGet()) + .RespondWith(WireMockResponse.Create().WithStatusCode(200).WithBody("Success")); + + var request = new JsonRequest() + { + BaseUrl = _baseUrl, + Method = HttpMethod.Get, + Path = "/test", + Query = new Dictionary + { + { "sample", "value" }, + { "email", "bob+test@example.com" }, + { "%Complete", "100" }, + }, + Options = new RequestOptions(), + }; + + var httpRequest = await _rawClient.CreateHttpRequestAsync(request).ConfigureAwait(false); + var url = httpRequest.RequestUri!.AbsoluteUri; + + Assert.That(url, Does.Contain("sample=value")); + Assert.That(url, Does.Contain("email=bob%2Btest%40example.com")); + Assert.That(url, Does.Contain("%25Complete=100")); + } + + [TearDown] + public void TearDown() + { + _server.Dispose(); + _httpClient.Dispose(); + } +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Core/RawClientTests/RetriesTests.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Core/RawClientTests/RetriesTests.cs new file mode 100644 index 000000000000..7e434ebf75f4 --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Core/RawClientTests/RetriesTests.cs @@ -0,0 +1,327 @@ +using global::System.Net.Http; +using NUnit.Framework; +using SeedNullable.Core; +using WireMock.Server; +using SystemTask = global::System.Threading.Tasks.Task; +using WireMockRequest = WireMock.RequestBuilders.Request; +using WireMockResponse = WireMock.ResponseBuilders.Response; + +namespace SeedNullable.Test.Core.RawClientTests; + +[TestFixture] +[Parallelizable(ParallelScope.Self)] +public class RetriesTests +{ + private const int MaxRetries = 3; + private WireMockServer _server; + private HttpClient _httpClient; + private RawClient _rawClient; + private string _baseUrl; + + [SetUp] + public void SetUp() + { + _server = WireMockServer.Start(); + _baseUrl = _server.Url ?? ""; + _httpClient = new HttpClient { BaseAddress = new Uri(_baseUrl) }; + _rawClient = new RawClient( + new ClientOptions { HttpClient = _httpClient, MaxRetries = MaxRetries } + ) + { + BaseRetryDelay = 0, + }; + } + + [Test] + [TestCase(408)] + [TestCase(429)] + [TestCase(500)] + [TestCase(504)] + public async SystemTask SendRequestAsync_ShouldRetry_OnRetryableStatusCodes(int statusCode) + { + _server + .Given(WireMockRequest.Create().WithPath("/test").UsingGet()) + .InScenario("Retry") + .WillSetStateTo("Server Error") + .RespondWith(WireMockResponse.Create().WithStatusCode(statusCode)); + + _server + .Given(WireMockRequest.Create().WithPath("/test").UsingGet()) + .InScenario("Retry") + .WhenStateIs("Server Error") + .WillSetStateTo("Success") + .RespondWith(WireMockResponse.Create().WithStatusCode(statusCode)); + + _server + .Given(WireMockRequest.Create().WithPath("/test").UsingGet()) + .InScenario("Retry") + .WhenStateIs("Success") + .RespondWith(WireMockResponse.Create().WithStatusCode(200).WithBody("Success")); + + var request = new EmptyRequest + { + BaseUrl = _baseUrl, + Method = HttpMethod.Get, + Path = "/test", + }; + + var response = await _rawClient.SendRequestAsync(request); + Assert.That(response.StatusCode, Is.EqualTo(200)); + + var content = await response.Raw.Content.ReadAsStringAsync(); + using (Assert.EnterMultipleScope()) + { + Assert.That(content, Is.EqualTo("Success")); + + Assert.That(_server.LogEntries, Has.Count.EqualTo(MaxRetries)); + } + } + + [Test] + [TestCase(400)] + [TestCase(409)] + public async SystemTask SendRequestAsync_ShouldRetry_OnNonRetryableStatusCodes(int statusCode) + { + _server + .Given(WireMockRequest.Create().WithPath("/test").UsingGet()) + .InScenario("Retry") + .WillSetStateTo("Server Error") + .RespondWith(WireMockResponse.Create().WithStatusCode(statusCode).WithBody("Failure")); + + var request = new JsonRequest + { + BaseUrl = _baseUrl, + Method = HttpMethod.Get, + Path = "/test", + Body = new { }, + }; + + var response = await _rawClient.SendRequestAsync(request); + Assert.That(response.StatusCode, Is.EqualTo(statusCode)); + + var content = await response.Raw.Content.ReadAsStringAsync(); + Assert.Multiple(() => + { + Assert.That(content, Is.EqualTo("Failure")); + + Assert.That(_server.LogEntries, Has.Count.EqualTo(1)); + }); + } + + [Test] + public async SystemTask SendRequestAsync_ShouldNotRetry_WithStreamRequest() + { + _server + .Given(WireMockRequest.Create().WithPath("/test").UsingPost()) + .InScenario("Retry") + .WillSetStateTo("Server Error") + .RespondWith(WireMockResponse.Create().WithStatusCode(429).WithBody("Failure")); + + var request = new StreamRequest + { + BaseUrl = _baseUrl, + Method = HttpMethod.Post, + Path = "/test", + Body = new MemoryStream(), + }; + + var response = await _rawClient.SendRequestAsync(request); + Assert.That(response.StatusCode, Is.EqualTo(429)); + + var content = await response.Raw.Content.ReadAsStringAsync(); + Assert.Multiple(() => + { + Assert.That(content, Is.EqualTo("Failure")); + Assert.That(_server.LogEntries, Has.Count.EqualTo(1)); + }); + } + + [Test] + public async SystemTask SendRequestAsync_ShouldNotRetry_WithMultiPartFormRequest_WithStream() + { + _server + .Given(WireMockRequest.Create().WithPath("/test").UsingPost()) + .InScenario("Retry") + .WillSetStateTo("Server Error") + .RespondWith(WireMockResponse.Create().WithStatusCode(429).WithBody("Failure")); + + var request = new SeedNullable.Core.MultipartFormRequest + { + BaseUrl = _baseUrl, + Method = HttpMethod.Post, + Path = "/test", + }; + request.AddFileParameterPart("file", new MemoryStream()); + + var response = await _rawClient.SendRequestAsync(request); + Assert.That(response.StatusCode, Is.EqualTo(429)); + + var content = await response.Raw.Content.ReadAsStringAsync(); + Assert.Multiple(() => + { + Assert.That(content, Is.EqualTo("Failure")); + Assert.That(_server.LogEntries, Has.Count.EqualTo(1)); + }); + } + + [Test] + public async SystemTask SendRequestAsync_ShouldRetry_WithMultiPartFormRequest_WithoutStream() + { + _server + .Given(WireMockRequest.Create().WithPath("/test").UsingPost()) + .InScenario("Retry") + .WillSetStateTo("Server Error") + .RespondWith(WireMockResponse.Create().WithStatusCode(429)); + + _server + .Given(WireMockRequest.Create().WithPath("/test").UsingPost()) + .InScenario("Retry") + .WhenStateIs("Server Error") + .WillSetStateTo("Success") + .RespondWith(WireMockResponse.Create().WithStatusCode(429)); + + _server + .Given(WireMockRequest.Create().WithPath("/test").UsingPost()) + .InScenario("Retry") + .WhenStateIs("Success") + .RespondWith(WireMockResponse.Create().WithStatusCode(200).WithBody("Success")); + + var request = new SeedNullable.Core.MultipartFormRequest + { + BaseUrl = _baseUrl, + Method = HttpMethod.Post, + Path = "/test", + }; + request.AddJsonPart("object", new { }); + + var response = await _rawClient.SendRequestAsync(request); + Assert.That(response.StatusCode, Is.EqualTo(200)); + + var content = await response.Raw.Content.ReadAsStringAsync(); + Assert.Multiple(() => + { + Assert.That(content, Is.EqualTo("Success")); + Assert.That(_server.LogEntries, Has.Count.EqualTo(MaxRetries)); + }); + } + + [Test] + public async SystemTask SendRequestAsync_ShouldRespectRetryAfterHeader_WithSecondsValue() + { + _server + .Given(WireMockRequest.Create().WithPath("/test").UsingGet()) + .InScenario("RetryAfter") + .WillSetStateTo("Success") + .RespondWith( + WireMockResponse.Create().WithStatusCode(429).WithHeader("Retry-After", "1") + ); + + _server + .Given(WireMockRequest.Create().WithPath("/test").UsingGet()) + .InScenario("RetryAfter") + .WhenStateIs("Success") + .RespondWith(WireMockResponse.Create().WithStatusCode(200).WithBody("Success")); + + var request = new EmptyRequest + { + BaseUrl = _baseUrl, + Method = HttpMethod.Get, + Path = "/test", + }; + + var response = await _rawClient.SendRequestAsync(request); + Assert.That(response.StatusCode, Is.EqualTo(200)); + + var content = await response.Raw.Content.ReadAsStringAsync(); + Assert.Multiple(() => + { + Assert.That(content, Is.EqualTo("Success")); + Assert.That(_server.LogEntries, Has.Count.EqualTo(2)); + }); + } + + [Test] + public async SystemTask SendRequestAsync_ShouldRespectRetryAfterHeader_WithHttpDateValue() + { + var retryAfterDate = DateTimeOffset.UtcNow.AddSeconds(1).ToString("R"); + _server + .Given(WireMockRequest.Create().WithPath("/test").UsingGet()) + .InScenario("RetryAfterDate") + .WillSetStateTo("Success") + .RespondWith( + WireMockResponse + .Create() + .WithStatusCode(429) + .WithHeader("Retry-After", retryAfterDate) + ); + + _server + .Given(WireMockRequest.Create().WithPath("/test").UsingGet()) + .InScenario("RetryAfterDate") + .WhenStateIs("Success") + .RespondWith(WireMockResponse.Create().WithStatusCode(200).WithBody("Success")); + + var request = new EmptyRequest + { + BaseUrl = _baseUrl, + Method = HttpMethod.Get, + Path = "/test", + }; + + var response = await _rawClient.SendRequestAsync(request); + Assert.That(response.StatusCode, Is.EqualTo(200)); + + var content = await response.Raw.Content.ReadAsStringAsync(); + Assert.Multiple(() => + { + Assert.That(content, Is.EqualTo("Success")); + Assert.That(_server.LogEntries, Has.Count.EqualTo(2)); + }); + } + + [Test] + public async SystemTask SendRequestAsync_ShouldRespectXRateLimitResetHeader() + { + var resetTime = DateTimeOffset.UtcNow.AddSeconds(1).ToUnixTimeSeconds().ToString(); + _server + .Given(WireMockRequest.Create().WithPath("/test").UsingGet()) + .InScenario("RateLimitReset") + .WillSetStateTo("Success") + .RespondWith( + WireMockResponse + .Create() + .WithStatusCode(429) + .WithHeader("X-RateLimit-Reset", resetTime) + ); + + _server + .Given(WireMockRequest.Create().WithPath("/test").UsingGet()) + .InScenario("RateLimitReset") + .WhenStateIs("Success") + .RespondWith(WireMockResponse.Create().WithStatusCode(200).WithBody("Success")); + + var request = new EmptyRequest + { + BaseUrl = _baseUrl, + Method = HttpMethod.Get, + Path = "/test", + }; + + var response = await _rawClient.SendRequestAsync(request); + Assert.That(response.StatusCode, Is.EqualTo(200)); + + var content = await response.Raw.Content.ReadAsStringAsync(); + Assert.Multiple(() => + { + Assert.That(content, Is.EqualTo("Success")); + Assert.That(_server.LogEntries, Has.Count.EqualTo(2)); + }); + } + + [TearDown] + public void TearDown() + { + _server.Dispose(); + _httpClient.Dispose(); + } +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/SeedNullable.Test.Custom.props b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/SeedNullable.Test.Custom.props new file mode 100644 index 000000000000..aac9b5020d80 --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/SeedNullable.Test.Custom.props @@ -0,0 +1,6 @@ + + diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/SeedNullable.Test.csproj b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/SeedNullable.Test.csproj new file mode 100644 index 000000000000..7a94d072ffbc --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/SeedNullable.Test.csproj @@ -0,0 +1,39 @@ + + + net8.0 + 12 + enable + enable + false + true + true + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/TestClient.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/TestClient.cs new file mode 100644 index 000000000000..7c81f0ff705a --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/TestClient.cs @@ -0,0 +1,6 @@ +using NUnit.Framework; + +namespace SeedNullable.Test; + +[TestFixture] +public class TestClient; diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Unit/MockServer/BaseMockServerTest.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Unit/MockServer/BaseMockServerTest.cs new file mode 100644 index 000000000000..714070abc584 --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Unit/MockServer/BaseMockServerTest.cs @@ -0,0 +1,38 @@ +using NUnit.Framework; +using SeedNullable; +using WireMock.Logging; +using WireMock.Server; +using WireMock.Settings; + +namespace SeedNullable.Test.Unit.MockServer; + +[SetUpFixture] +public class BaseMockServerTest +{ + protected static WireMockServer Server { get; set; } = null!; + + protected static SeedNullableClient Client { get; set; } = null!; + + protected static RequestOptions RequestOptions { get; set; } = new(); + + [OneTimeSetUp] + public void GlobalSetup() + { + // Start the WireMock server + Server = WireMockServer.Start( + new WireMockServerSettings { Logger = new WireMockConsoleLogger() } + ); + + // Initialize the Client + Client = new SeedNullableClient( + clientOptions: new ClientOptions { BaseUrl = Server.Urls[0], MaxRetries = 0 } + ); + } + + [OneTimeTearDown] + public void GlobalTeardown() + { + Server.Stop(); + Server.Dispose(); + } +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Unit/MockServer/CreateUserTest.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Unit/MockServer/CreateUserTest.cs new file mode 100644 index 000000000000..481eb49067a6 --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Unit/MockServer/CreateUserTest.cs @@ -0,0 +1,116 @@ +using System.Globalization; +using NUnit.Framework; +using SeedNullable; +using SeedNullable.Core; + +namespace SeedNullable.Test.Unit.MockServer; + +[TestFixture] +public class CreateUserTest : BaseMockServerTest +{ + [NUnit.Framework.Test] + public async Task MockServerTest() + { + const string requestJson = """ + { + "username": "username", + "tags": [ + "tags", + "tags" + ], + "metadata": { + "createdAt": "2024-01-15T09:30:00.000Z", + "updatedAt": "2024-01-15T09:30:00.000Z", + "avatar": "avatar", + "activated": true, + "status": { + "type": "active" + }, + "values": { + "values": "values" + } + }, + "avatar": "avatar" + } + """; + + const string mockResponse = """ + { + "name": "name", + "id": "id", + "tags": [ + "tags", + "tags" + ], + "metadata": { + "createdAt": "2024-01-15T09:30:00.000Z", + "updatedAt": "2024-01-15T09:30:00.000Z", + "avatar": "avatar", + "activated": true, + "status": { + "type": "active" + }, + "values": { + "values": "values" + } + }, + "email": "email", + "favorite-number": 1, + "numbers": [ + 1, + 1 + ], + "strings": { + "strings": { + "key": "value" + } + } + } + """; + + Server + .Given( + WireMock + .RequestBuilders.Request.Create() + .WithPath("/users") + .UsingPost() + .WithBodyAsJson(requestJson) + ) + .RespondWith( + WireMock + .ResponseBuilders.Response.Create() + .WithStatusCode(200) + .WithBody(mockResponse) + ); + + var response = await Client.Nullable.CreateUserAsync( + new CreateUserRequest + { + Username = "username", + Tags = new List() { "tags", "tags" }, + Metadata = new Metadata + { + CreatedAt = DateTime.Parse( + "2024-01-15T09:30:00.000Z", + null, + DateTimeStyles.AdjustToUniversal + ), + UpdatedAt = DateTime.Parse( + "2024-01-15T09:30:00.000Z", + null, + DateTimeStyles.AdjustToUniversal + ), + Avatar = "avatar", + Activated = true, + Status = new Status(new Status.Active()), + Values = new Dictionary() { { "values", "values" } }, + }, + Avatar = "avatar", + } + ); + Assert.That( + response, + Is.EqualTo(JsonUtils.Deserialize(mockResponse)).UsingDefaults() + ); + } +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Unit/MockServer/DeleteUserTest.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Unit/MockServer/DeleteUserTest.cs new file mode 100644 index 000000000000..6568365a26c5 --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Unit/MockServer/DeleteUserTest.cs @@ -0,0 +1,43 @@ +using NUnit.Framework; +using SeedNullable; +using SeedNullable.Core; + +namespace SeedNullable.Test.Unit.MockServer; + +[TestFixture] +public class DeleteUserTest : BaseMockServerTest +{ + [NUnit.Framework.Test] + public async Task MockServerTest() + { + const string requestJson = """ + { + "username": "xy" + } + """; + + const string mockResponse = """ + true + """; + + Server + .Given( + WireMock + .RequestBuilders.Request.Create() + .WithPath("/users") + .UsingDelete() + .WithBodyAsJson(requestJson) + ) + .RespondWith( + WireMock + .ResponseBuilders.Response.Create() + .WithStatusCode(200) + .WithBody(mockResponse) + ); + + var response = await Client.Nullable.DeleteUserAsync( + new DeleteUserRequest { Username = "xy" } + ); + Assert.That(response, Is.EqualTo(JsonUtils.Deserialize(mockResponse))); + } +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Unit/MockServer/GetUsersTest.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Unit/MockServer/GetUsersTest.cs new file mode 100644 index 000000000000..f25e1d142e3a --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Unit/MockServer/GetUsersTest.cs @@ -0,0 +1,112 @@ +using NUnit.Framework; +using SeedNullable; +using SeedNullable.Core; + +namespace SeedNullable.Test.Unit.MockServer; + +[TestFixture] +public class GetUsersTest : BaseMockServerTest +{ + [NUnit.Framework.Test] + public async Task MockServerTest() + { + const string mockResponse = """ + [ + { + "name": "name", + "id": "id", + "tags": [ + "tags", + "tags" + ], + "metadata": { + "createdAt": "2024-01-15T09:30:00.000Z", + "updatedAt": "2024-01-15T09:30:00.000Z", + "avatar": "avatar", + "activated": true, + "status": { + "type": "active" + }, + "values": { + "values": "values" + } + }, + "email": "email", + "favorite-number": 1, + "numbers": [ + 1, + 1 + ], + "strings": { + "strings": { + "key": "value" + } + } + }, + { + "name": "name", + "id": "id", + "tags": [ + "tags", + "tags" + ], + "metadata": { + "createdAt": "2024-01-15T09:30:00.000Z", + "updatedAt": "2024-01-15T09:30:00.000Z", + "avatar": "avatar", + "activated": true, + "status": { + "type": "active" + }, + "values": { + "values": "values" + } + }, + "email": "email", + "favorite-number": 1, + "numbers": [ + 1, + 1 + ], + "strings": { + "strings": { + "key": "value" + } + } + } + ] + """; + + Server + .Given( + WireMock + .RequestBuilders.Request.Create() + .WithPath("/users") + .WithParam("usernames", "usernames") + .WithParam("avatar", "avatar") + .WithParam("tags", "tags") + .UsingGet() + ) + .RespondWith( + WireMock + .ResponseBuilders.Response.Create() + .WithStatusCode(200) + .WithBody(mockResponse) + ); + + var response = await Client.Nullable.GetUsersAsync( + new GetUsersRequest + { + Usernames = ["usernames"], + Avatar = "avatar", + Activated = [true], + Tags = ["tags"], + Extra = true, + } + ); + Assert.That( + response, + Is.EqualTo(JsonUtils.Deserialize>(mockResponse)).UsingDefaults() + ); + } +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Utils/JsonElementComparer.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Utils/JsonElementComparer.cs new file mode 100644 index 000000000000..1704c99af443 --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Utils/JsonElementComparer.cs @@ -0,0 +1,236 @@ +using System.Text.Json; +using NUnit.Framework.Constraints; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle JsonElement objects. +/// +public static class JsonElementComparerExtensions +{ + /// + /// Extension method for comparing JsonElement objects in NUnit tests. + /// Property order doesn't matter, but array order does matter. + /// Includes special handling for DateTime string formats. + /// + /// The Is.EqualTo() constraint instance. + /// A constraint that can compare JsonElements with detailed diffs. + public static EqualConstraint UsingJsonElementComparer(this EqualConstraint constraint) + { + return constraint.Using(new JsonElementComparer()); + } +} + +/// +/// Equality comparer for JsonElement with detailed reporting. +/// Property order doesn't matter, but array order does matter. +/// Now includes special handling for DateTime string formats with improved null handling. +/// +public class JsonElementComparer : IEqualityComparer +{ + private string _failurePath = string.Empty; + + /// + public bool Equals(JsonElement x, JsonElement y) + { + _failurePath = string.Empty; + return CompareJsonElements(x, y, string.Empty); + } + + /// + public int GetHashCode(JsonElement obj) + { + return JsonSerializer.Serialize(obj).GetHashCode(); + } + + private bool CompareJsonElements(JsonElement x, JsonElement y, string path) + { + // If value kinds don't match, they're not equivalent + if (x.ValueKind != y.ValueKind) + { + _failurePath = $"{path}: Expected {x.ValueKind} but got {y.ValueKind}"; + return false; + } + + switch (x.ValueKind) + { + case JsonValueKind.Object: + return CompareJsonObjects(x, y, path); + + case JsonValueKind.Array: + return CompareJsonArraysInOrder(x, y, path); + + case JsonValueKind.String: + string? xStr = x.GetString(); + string? yStr = y.GetString(); + + // Handle null strings + if (xStr is null && yStr is null) + return true; + + if (xStr is null || yStr is null) + { + _failurePath = + $"{path}: Expected {(xStr is null ? "null" : $"\"{xStr}\"")} but got {(yStr is null ? "null" : $"\"{yStr}\"")}"; + return false; + } + + // Check if they are identical strings + if (xStr == yStr) + return true; + + // Try to handle DateTime strings + if (IsLikelyDateTimeString(xStr) && IsLikelyDateTimeString(yStr)) + { + if (AreEquivalentDateTimeStrings(xStr, yStr)) + return true; + } + + _failurePath = $"{path}: Expected \"{xStr}\" but got \"{yStr}\""; + return false; + + case JsonValueKind.Number: + if (x.GetDecimal() != y.GetDecimal()) + { + _failurePath = $"{path}: Expected {x.GetDecimal()} but got {y.GetDecimal()}"; + return false; + } + + return true; + + case JsonValueKind.True: + case JsonValueKind.False: + if (x.GetBoolean() != y.GetBoolean()) + { + _failurePath = $"{path}: Expected {x.GetBoolean()} but got {y.GetBoolean()}"; + return false; + } + + return true; + + case JsonValueKind.Null: + return true; + + default: + _failurePath = $"{path}: Unsupported JsonValueKind {x.ValueKind}"; + return false; + } + } + + private bool IsLikelyDateTimeString(string? str) + { + // Simple heuristic to identify likely ISO date time strings + return str != null + && (str.Contains("T") && (str.EndsWith("Z") || str.Contains("+") || str.Contains("-"))); + } + + private bool AreEquivalentDateTimeStrings(string str1, string str2) + { + // Try to parse both as DateTime + if (DateTime.TryParse(str1, out DateTime dt1) && DateTime.TryParse(str2, out DateTime dt2)) + { + return dt1 == dt2; + } + + return false; + } + + private bool CompareJsonObjects(JsonElement x, JsonElement y, string path) + { + // Create dictionaries for both JSON objects + var xProps = new Dictionary(); + var yProps = new Dictionary(); + + foreach (var prop in x.EnumerateObject()) + xProps[prop.Name] = prop.Value; + + foreach (var prop in y.EnumerateObject()) + yProps[prop.Name] = prop.Value; + + // Check if all properties in x exist in y + foreach (var key in xProps.Keys) + { + if (!yProps.ContainsKey(key)) + { + _failurePath = $"{path}: Missing property '{key}'"; + return false; + } + } + + // Check if y has extra properties + foreach (var key in yProps.Keys) + { + if (!xProps.ContainsKey(key)) + { + _failurePath = $"{path}: Unexpected property '{key}'"; + return false; + } + } + + // Compare each property value + foreach (var key in xProps.Keys) + { + var propPath = string.IsNullOrEmpty(path) ? key : $"{path}.{key}"; + if (!CompareJsonElements(xProps[key], yProps[key], propPath)) + { + return false; + } + } + + return true; + } + + private bool CompareJsonArraysInOrder(JsonElement x, JsonElement y, string path) + { + var xArray = x.EnumerateArray(); + var yArray = y.EnumerateArray(); + + // Count x elements + var xCount = 0; + var xElements = new List(); + foreach (var item in xArray) + { + xElements.Add(item); + xCount++; + } + + // Count y elements + var yCount = 0; + var yElements = new List(); + foreach (var item in yArray) + { + yElements.Add(item); + yCount++; + } + + // Check if counts match + if (xCount != yCount) + { + _failurePath = $"{path}: Expected {xCount} items but found {yCount}"; + return false; + } + + // Compare elements in order + for (var i = 0; i < xCount; i++) + { + var itemPath = $"{path}[{i}]"; + if (!CompareJsonElements(xElements[i], yElements[i], itemPath)) + { + return false; + } + } + + return true; + } + + /// + public override string ToString() + { + if (!string.IsNullOrEmpty(_failurePath)) + { + return $"JSON comparison failed at {_failurePath}"; + } + + return "JsonElementEqualityComparer"; + } +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Utils/NUnitExtensions.cs new file mode 100644 index 000000000000..78e90e0a90fc --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Utils/NUnitExtensions.cs @@ -0,0 +1,29 @@ +using NUnit.Framework.Constraints; + +namespace NUnit.Framework; + +/// +/// Extensions for NUnit constraints. +/// +public static class NUnitExtensions +{ + /// + /// Modifies the EqualConstraint to use our own set of default comparers. + /// + /// + /// + public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => + constraint + .UsingPropertiesComparer() + .UsingReadOnlyMemoryComparer() + .UsingReadOnlyMemoryComparer() + .UsingReadOnlyMemoryComparer() + .UsingReadOnlyMemoryComparer() + .UsingReadOnlyMemoryComparer() + .UsingReadOnlyMemoryComparer() + .UsingReadOnlyMemoryComparer() + .UsingReadOnlyMemoryComparer() + .UsingOneOfComparer() + .UsingJsonElementComparer() + .UsingOptionalComparer(); +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Utils/OneOfComparer.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Utils/OneOfComparer.cs new file mode 100644 index 000000000000..0c975b471ff3 --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Utils/OneOfComparer.cs @@ -0,0 +1,43 @@ +using NUnit.Framework.Constraints; +using OneOf; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle OneOf values. +/// +public static class EqualConstraintExtensions +{ + /// + /// Modifies the EqualConstraint to handle OneOf instances by comparing their inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOneOfComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOneOf types + constraint.Using( + (x, y) => + { + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (x.Value is null && y.Value is null) + { + return true; + } + + if (x.Value is null) + { + return false; + } + + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(x.Value, y.Value, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..aa057ddc2c2e --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedNullable.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Utils/ReadOnlyMemoryComparer.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Utils/ReadOnlyMemoryComparer.cs new file mode 100644 index 000000000000..fc0b595a5e54 --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable.Test/Utils/ReadOnlyMemoryComparer.cs @@ -0,0 +1,87 @@ +using NUnit.Framework.Constraints; + +namespace NUnit.Framework; + +/// +/// Extensions for NUnit constraints. +/// +public static class ReadOnlyMemoryComparerExtensions +{ + /// + /// Extension method for comparing ReadOnlyMemory<T> in NUnit tests. + /// + /// The type of elements in the ReadOnlyMemory. + /// The Is.EqualTo() constraint instance. + /// A constraint that can compare ReadOnlyMemory<T>. + public static EqualConstraint UsingReadOnlyMemoryComparer(this EqualConstraint constraint) + where T : IComparable + { + return constraint.Using(new ReadOnlyMemoryComparer()); + } +} + +/// +/// Comparer for ReadOnlyMemory<T>. Compares sequences by value. +/// +/// +/// The type of elements in the ReadOnlyMemory. +/// +public class ReadOnlyMemoryComparer : IComparer> + where T : IComparable +{ + /// + public int Compare(ReadOnlyMemory x, ReadOnlyMemory y) + { + // Check if sequences are equal + var xSpan = x.Span; + var ySpan = y.Span; + + // Optimized case for IEquatable implementations + if (typeof(IEquatable).IsAssignableFrom(typeof(T))) + { + var areEqual = xSpan.SequenceEqual(ySpan); + if (areEqual) + { + return 0; // Sequences are equal + } + } + else + { + // Manual equality check for non-IEquatable types + if (xSpan.Length == ySpan.Length) + { + var areEqual = true; + for (var i = 0; i < xSpan.Length; i++) + { + if (!EqualityComparer.Default.Equals(xSpan[i], ySpan[i])) + { + areEqual = false; + break; + } + } + + if (areEqual) + { + return 0; // Sequences are equal + } + } + } + + // For non-equal sequences, we need to return a consistent ordering + // First compare lengths + if (x.Length != y.Length) + return x.Length.CompareTo(y.Length); + + // Same length but different content - compare first differing element + for (var i = 0; i < x.Length; i++) + { + if (!EqualityComparer.Default.Equals(xSpan[i], ySpan[i])) + { + return xSpan[i].CompareTo(ySpan[i]); + } + } + + // Should never reach here if not equal + return 0; + } +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/ApiResponse.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/ApiResponse.cs new file mode 100644 index 000000000000..903bd6b056c7 --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/ApiResponse.cs @@ -0,0 +1,13 @@ +using System.Net.Http; + +namespace SeedNullable.Core; + +/// +/// The response object returned from the API. +/// +internal record ApiResponse +{ + internal required int StatusCode { get; init; } + + internal required HttpResponseMessage Raw { get; init; } +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/BaseRequest.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/BaseRequest.cs new file mode 100644 index 000000000000..37d2da6ab765 --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/BaseRequest.cs @@ -0,0 +1,63 @@ +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; + +namespace SeedNullable.Core; + +internal abstract record BaseRequest +{ + internal required string BaseUrl { get; init; } + + internal required HttpMethod Method { get; init; } + + internal required string Path { get; init; } + + internal string? ContentType { get; init; } + + internal Dictionary Query { get; init; } = new(); + + internal Headers Headers { get; init; } = new(); + + internal IRequestOptions? Options { get; init; } + + internal abstract HttpContent? CreateContent(); + + protected static ( + Encoding encoding, + string? charset, + string mediaType + ) ParseContentTypeOrDefault( + string? contentType, + Encoding encodingFallback, + string mediaTypeFallback + ) + { + var encoding = encodingFallback; + var mediaType = mediaTypeFallback; + string? charset = null; + if (string.IsNullOrEmpty(contentType)) + { + return (encoding, charset, mediaType); + } + + if (!MediaTypeHeaderValue.TryParse(contentType, out var mediaTypeHeaderValue)) + { + return (encoding, charset, mediaType); + } + + if (!string.IsNullOrEmpty(mediaTypeHeaderValue.CharSet)) + { + charset = mediaTypeHeaderValue.CharSet; + encoding = Encoding.GetEncoding(mediaTypeHeaderValue.CharSet); + } + + if (!string.IsNullOrEmpty(mediaTypeHeaderValue.MediaType)) + { + mediaType = mediaTypeHeaderValue.MediaType; + } + + return (encoding, charset, mediaType); + } + + protected static Encoding Utf8NoBom => EncodingCache.Utf8NoBom; +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/CollectionItemSerializer.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/CollectionItemSerializer.cs new file mode 100644 index 000000000000..e6c3c9b367ee --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/CollectionItemSerializer.cs @@ -0,0 +1,89 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedNullable.Core; + +/// +/// Json collection converter. +/// +/// Type of item to convert. +/// Converter to use for individual items. +internal class CollectionItemSerializer + : JsonConverter> + where TConverterType : JsonConverter +{ + /// + /// Reads a json string and deserializes it into an object. + /// + /// Json reader. + /// Type to convert. + /// Serializer options. + /// Created object. + public override IEnumerable? Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return default; + } + + var jsonSerializerOptions = new JsonSerializerOptions(options); + jsonSerializerOptions.Converters.Clear(); + jsonSerializerOptions.Converters.Add(Activator.CreateInstance()); + + var returnValue = new List(); + + while (reader.TokenType != JsonTokenType.EndArray) + { + if (reader.TokenType != JsonTokenType.StartArray) + { + var item = (TDatatype)( + JsonSerializer.Deserialize(ref reader, typeof(TDatatype), jsonSerializerOptions) + ?? throw new global::System.Exception( + $"Failed to deserialize collection item of type {typeof(TDatatype)}" + ) + ); + returnValue.Add(item); + } + + reader.Read(); + } + + return returnValue; + } + + /// + /// Writes a json string. + /// + /// Json writer. + /// Value to write. + /// Serializer options. + public override void Write( + Utf8JsonWriter writer, + IEnumerable? value, + JsonSerializerOptions options + ) + { + if (value == null) + { + writer.WriteNullValue(); + return; + } + + var jsonSerializerOptions = new JsonSerializerOptions(options); + jsonSerializerOptions.Converters.Clear(); + jsonSerializerOptions.Converters.Add(Activator.CreateInstance()); + + writer.WriteStartArray(); + + foreach (var data in value) + { + JsonSerializer.Serialize(writer, data, jsonSerializerOptions); + } + + writer.WriteEndArray(); + } +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/Constants.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/Constants.cs new file mode 100644 index 000000000000..251c678efda9 --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/Constants.cs @@ -0,0 +1,7 @@ +namespace SeedNullable.Core; + +internal static class Constants +{ + public const string DateTimeFormat = "yyyy'-'MM'-'dd'T'HH':'mm':'ss.fffK"; + public const string DateFormat = "yyyy-MM-dd"; +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/DateOnlyConverter.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/DateOnlyConverter.cs new file mode 100644 index 000000000000..94a3b2f89684 --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/DateOnlyConverter.cs @@ -0,0 +1,747 @@ +// ReSharper disable All +#pragma warning disable + +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using global::System.Diagnostics; +using global::System.Diagnostics.CodeAnalysis; +using global::System.Globalization; +using global::System.Runtime.CompilerServices; +using global::System.Runtime.InteropServices; +using global::System.Text.Json; +using global::System.Text.Json.Serialization; + +// ReSharper disable SuggestVarOrType_SimpleTypes +// ReSharper disable SuggestVarOrType_BuiltInTypes + +namespace SeedNullable.Core +{ + /// + /// Custom converter for handling the data type with the System.Text.Json library. + /// + /// + /// This class backported from: + /// + /// System.Text.Json.Serialization.Converters.DateOnlyConverter + /// + public sealed class DateOnlyConverter : JsonConverter + { + private const int FormatLength = 10; // YYYY-MM-DD + + private const int MaxEscapedFormatLength = + FormatLength * JsonConstants.MaxExpansionFactorWhileEscaping; + + /// + public override DateOnly Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType != JsonTokenType.String) + { + ThrowHelper.ThrowInvalidOperationException_ExpectedString(reader.TokenType); + } + + return ReadCore(ref reader); + } + + /// + public override DateOnly ReadAsPropertyName( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + Debug.Assert(reader.TokenType == JsonTokenType.PropertyName); + return ReadCore(ref reader); + } + + private static DateOnly ReadCore(ref Utf8JsonReader reader) + { + if ( + !JsonHelpers.IsInRangeInclusive( + reader.ValueLength(), + FormatLength, + MaxEscapedFormatLength + ) + ) + { + ThrowHelper.ThrowFormatException(DataType.DateOnly); + } + + scoped ReadOnlySpan source; + if (!reader.HasValueSequence && !reader.ValueIsEscaped) + { + source = reader.ValueSpan; + } + else + { + Span stackSpan = stackalloc byte[MaxEscapedFormatLength]; + int bytesWritten = reader.CopyString(stackSpan); + source = stackSpan.Slice(0, bytesWritten); + } + + if (!JsonHelpers.TryParseAsIso(source, out DateOnly value)) + { + ThrowHelper.ThrowFormatException(DataType.DateOnly); + } + + return value; + } + + /// + public override void Write( + Utf8JsonWriter writer, + DateOnly value, + JsonSerializerOptions options + ) + { +#if NET8_0_OR_GREATER + Span buffer = stackalloc byte[FormatLength]; +#else + Span buffer = stackalloc char[FormatLength]; +#endif + // ReSharper disable once RedundantAssignment + bool formattedSuccessfully = value.TryFormat( + buffer, + out int charsWritten, + "O".AsSpan(), + CultureInfo.InvariantCulture + ); + Debug.Assert(formattedSuccessfully && charsWritten == FormatLength); + writer.WriteStringValue(buffer); + } + + /// + public override void WriteAsPropertyName( + Utf8JsonWriter writer, + DateOnly value, + JsonSerializerOptions options + ) + { +#if NET8_0_OR_GREATER + Span buffer = stackalloc byte[FormatLength]; +#else + Span buffer = stackalloc char[FormatLength]; +#endif + // ReSharper disable once RedundantAssignment + bool formattedSuccessfully = value.TryFormat( + buffer, + out int charsWritten, + "O".AsSpan(), + CultureInfo.InvariantCulture + ); + Debug.Assert(formattedSuccessfully && charsWritten == FormatLength); + writer.WritePropertyName(buffer); + } + } + + internal static class JsonConstants + { + // The maximum number of fraction digits the Json DateTime parser allows + public const int DateTimeParseNumFractionDigits = 16; + + // In the worst case, an ASCII character represented as a single utf-8 byte could expand 6x when escaped. + public const int MaxExpansionFactorWhileEscaping = 6; + + // The largest fraction expressible by TimeSpan and DateTime formats + public const int MaxDateTimeFraction = 9_999_999; + + // TimeSpan and DateTime formats allow exactly up to many digits for specifying the fraction after the seconds. + public const int DateTimeNumFractionDigits = 7; + + public const byte UtcOffsetToken = (byte)'Z'; + + public const byte TimePrefix = (byte)'T'; + + public const byte Period = (byte)'.'; + + public const byte Hyphen = (byte)'-'; + + public const byte Colon = (byte)':'; + + public const byte Plus = (byte)'+'; + } + + // ReSharper disable SuggestVarOrType_Elsewhere + // ReSharper disable SuggestVarOrType_SimpleTypes + // ReSharper disable SuggestVarOrType_BuiltInTypes + + internal static class JsonHelpers + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsInRangeInclusive(int value, int lowerBound, int upperBound) => + (uint)(value - lowerBound) <= (uint)(upperBound - lowerBound); + + public static bool IsDigit(byte value) => (uint)(value - '0') <= '9' - '0'; + + [StructLayout(LayoutKind.Auto)] + private struct DateTimeParseData + { + public int Year; + public int Month; + public int Day; + public bool IsCalendarDateOnly; + public int Hour; + public int Minute; + public int Second; + public int Fraction; // This value should never be greater than 9_999_999. + public int OffsetHours; + public int OffsetMinutes; + + // ReSharper disable once NotAccessedField.Local + public byte OffsetToken; + } + + public static bool TryParseAsIso(ReadOnlySpan source, out DateOnly value) + { + if ( + TryParseDateTimeOffset(source, out DateTimeParseData parseData) + && parseData.IsCalendarDateOnly + && TryCreateDateTime(parseData, DateTimeKind.Unspecified, out DateTime dateTime) + ) + { + value = DateOnly.FromDateTime(dateTime); + return true; + } + + value = default; + return false; + } + + /// + /// ISO 8601 date time parser (ISO 8601-1:2019). + /// + /// The date/time to parse in UTF-8 format. + /// The parsed for the given . + /// + /// Supports extended calendar date (5.2.2.1) and complete (5.4.2.1) calendar date/time of day + /// representations with optional specification of seconds and fractional seconds. + /// + /// Times can be explicitly specified as UTC ("Z" - 5.3.3) or offsets from UTC ("+/-hh:mm" 5.3.4.2). + /// If unspecified they are considered to be local per spec. + /// + /// Examples: (TZD is either "Z" or hh:mm offset from UTC) + /// + /// YYYY-MM-DD (e.g. 1997-07-16) + /// YYYY-MM-DDThh:mm (e.g. 1997-07-16T19:20) + /// YYYY-MM-DDThh:mm:ss (e.g. 1997-07-16T19:20:30) + /// YYYY-MM-DDThh:mm:ss.s (e.g. 1997-07-16T19:20:30.45) + /// YYYY-MM-DDThh:mmTZD (e.g. 1997-07-16T19:20+01:00) + /// YYYY-MM-DDThh:mm:ssTZD (e.g. 1997-07-16T19:20:3001:00) + /// YYYY-MM-DDThh:mm:ss.sTZD (e.g. 1997-07-16T19:20:30.45Z) + /// + /// Generally speaking we always require the "extended" option when one exists (3.1.3.5). + /// The extended variants have separator characters between components ('-', ':', '.', etc.). + /// Spaces are not permitted. + /// + /// "true" if successfully parsed. + private static bool TryParseDateTimeOffset( + ReadOnlySpan source, + out DateTimeParseData parseData + ) + { + parseData = default; + + // too short datetime + Debug.Assert(source.Length >= 10); + + // Parse the calendar date + // ----------------------- + // ISO 8601-1:2019 5.2.2.1b "Calendar date complete extended format" + // [dateX] = [year]["-"][month]["-"][day] + // [year] = [YYYY] [0000 - 9999] (4.3.2) + // [month] = [MM] [01 - 12] (4.3.3) + // [day] = [DD] [01 - 28, 29, 30, 31] (4.3.4) + // + // Note: 5.2.2.2 "Representations with reduced precision" allows for + // just [year]["-"][month] (a) and just [year] (b), but we currently + // don't permit it. + + { + uint digit1 = source[0] - (uint)'0'; + uint digit2 = source[1] - (uint)'0'; + uint digit3 = source[2] - (uint)'0'; + uint digit4 = source[3] - (uint)'0'; + + if (digit1 > 9 || digit2 > 9 || digit3 > 9 || digit4 > 9) + { + return false; + } + + parseData.Year = (int)(digit1 * 1000 + digit2 * 100 + digit3 * 10 + digit4); + } + + if ( + source[4] != JsonConstants.Hyphen + || !TryGetNextTwoDigits(source.Slice(start: 5, length: 2), ref parseData.Month) + || source[7] != JsonConstants.Hyphen + || !TryGetNextTwoDigits(source.Slice(start: 8, length: 2), ref parseData.Day) + ) + { + return false; + } + + // We now have YYYY-MM-DD [dateX] + // ReSharper disable once ConvertIfStatementToSwitchStatement + if (source.Length == 10) + { + parseData.IsCalendarDateOnly = true; + return true; + } + + // Parse the time of day + // --------------------- + // + // ISO 8601-1:2019 5.3.1.2b "Local time of day complete extended format" + // [timeX] = ["T"][hour][":"][min][":"][sec] + // [hour] = [hh] [00 - 23] (4.3.8a) + // [minute] = [mm] [00 - 59] (4.3.9a) + // [sec] = [ss] [00 - 59, 60 with a leap second] (4.3.10a) + // + // ISO 8601-1:2019 5.3.3 "UTC of day" + // [timeX]["Z"] + // + // ISO 8601-1:2019 5.3.4.2 "Local time of day with the time shift between + // local timescale and UTC" (Extended format) + // + // [shiftX] = ["+"|"-"][hour][":"][min] + // + // Notes: + // + // "T" is optional per spec, but _only_ when times are used alone. In our + // case, we're reading out a complete date & time and as such require "T". + // (5.4.2.1b). + // + // For [timeX] We allow seconds to be omitted per 5.3.1.3a "Representations + // with reduced precision". 5.3.1.3b allows just specifying the hour, but + // we currently don't permit this. + // + // Decimal fractions are allowed for hours, minutes and seconds (5.3.14). + // We only allow fractions for seconds currently. Lower order components + // can't follow, i.e. you can have T23.3, but not T23.3:04. There must be + // one digit, but the max number of digits is implementation defined. We + // currently allow up to 16 digits of fractional seconds only. While we + // support 16 fractional digits we only parse the first seven, anything + // past that is considered a zero. This is to stay compatible with the + // DateTime implementation which is limited to this resolution. + + if (source.Length < 16) + { + // Source does not have enough characters for YYYY-MM-DDThh:mm + return false; + } + + // Parse THH:MM (e.g. "T10:32") + if ( + source[10] != JsonConstants.TimePrefix + || source[13] != JsonConstants.Colon + || !TryGetNextTwoDigits(source.Slice(start: 11, length: 2), ref parseData.Hour) + || !TryGetNextTwoDigits(source.Slice(start: 14, length: 2), ref parseData.Minute) + ) + { + return false; + } + + // We now have YYYY-MM-DDThh:mm + Debug.Assert(source.Length >= 16); + if (source.Length == 16) + { + return true; + } + + byte curByte = source[16]; + int sourceIndex = 17; + + // Either a TZD ['Z'|'+'|'-'] or a seconds separator [':'] is valid at this point + switch (curByte) + { + case JsonConstants.UtcOffsetToken: + parseData.OffsetToken = JsonConstants.UtcOffsetToken; + return sourceIndex == source.Length; + case JsonConstants.Plus: + case JsonConstants.Hyphen: + parseData.OffsetToken = curByte; + return ParseOffset(ref parseData, source.Slice(sourceIndex)); + case JsonConstants.Colon: + break; + default: + return false; + } + + // Try reading the seconds + if ( + source.Length < 19 + || !TryGetNextTwoDigits(source.Slice(start: 17, length: 2), ref parseData.Second) + ) + { + return false; + } + + // We now have YYYY-MM-DDThh:mm:ss + Debug.Assert(source.Length >= 19); + if (source.Length == 19) + { + return true; + } + + curByte = source[19]; + sourceIndex = 20; + + // Either a TZD ['Z'|'+'|'-'] or a seconds decimal fraction separator ['.'] is valid at this point + switch (curByte) + { + case JsonConstants.UtcOffsetToken: + parseData.OffsetToken = JsonConstants.UtcOffsetToken; + return sourceIndex == source.Length; + case JsonConstants.Plus: + case JsonConstants.Hyphen: + parseData.OffsetToken = curByte; + return ParseOffset(ref parseData, source.Slice(sourceIndex)); + case JsonConstants.Period: + break; + default: + return false; + } + + // Source does not have enough characters for second fractions (i.e. ".s") + // YYYY-MM-DDThh:mm:ss.s + if (source.Length < 21) + { + return false; + } + + // Parse fraction. This value should never be greater than 9_999_999 + int numDigitsRead = 0; + int fractionEnd = Math.Min( + sourceIndex + JsonConstants.DateTimeParseNumFractionDigits, + source.Length + ); + + while (sourceIndex < fractionEnd && IsDigit(curByte = source[sourceIndex])) + { + if (numDigitsRead < JsonConstants.DateTimeNumFractionDigits) + { + parseData.Fraction = parseData.Fraction * 10 + (int)(curByte - (uint)'0'); + numDigitsRead++; + } + + sourceIndex++; + } + + if (parseData.Fraction != 0) + { + while (numDigitsRead < JsonConstants.DateTimeNumFractionDigits) + { + parseData.Fraction *= 10; + numDigitsRead++; + } + } + + // We now have YYYY-MM-DDThh:mm:ss.s + Debug.Assert(sourceIndex <= source.Length); + if (sourceIndex == source.Length) + { + return true; + } + + curByte = source[sourceIndex++]; + + // TZD ['Z'|'+'|'-'] is valid at this point + switch (curByte) + { + case JsonConstants.UtcOffsetToken: + parseData.OffsetToken = JsonConstants.UtcOffsetToken; + return sourceIndex == source.Length; + case JsonConstants.Plus: + case JsonConstants.Hyphen: + parseData.OffsetToken = curByte; + return ParseOffset(ref parseData, source.Slice(sourceIndex)); + default: + return false; + } + + static bool ParseOffset(ref DateTimeParseData parseData, ReadOnlySpan offsetData) + { + // Parse the hours for the offset + if ( + offsetData.Length < 2 + || !TryGetNextTwoDigits(offsetData.Slice(0, 2), ref parseData.OffsetHours) + ) + { + return false; + } + + // We now have YYYY-MM-DDThh:mm:ss.s+|-hh + + if (offsetData.Length == 2) + { + // Just hours offset specified + return true; + } + + // Ensure we have enough for ":mm" + return offsetData.Length == 5 + && offsetData[2] == JsonConstants.Colon + && TryGetNextTwoDigits(offsetData.Slice(3), ref parseData.OffsetMinutes); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + // ReSharper disable once RedundantAssignment + private static bool TryGetNextTwoDigits(ReadOnlySpan source, ref int value) + { + Debug.Assert(source.Length == 2); + + uint digit1 = source[0] - (uint)'0'; + uint digit2 = source[1] - (uint)'0'; + + if (digit1 > 9 || digit2 > 9) + { + value = 0; + return false; + } + + value = (int)(digit1 * 10 + digit2); + return true; + } + + // The following methods are borrowed verbatim from src/Common/src/CoreLib/System/Buffers/Text/Utf8Parser/Utf8Parser.Date.Helpers.cs + + /// + /// Overflow-safe DateTime factory. + /// + private static bool TryCreateDateTime( + DateTimeParseData parseData, + DateTimeKind kind, + out DateTime value + ) + { + if (parseData.Year == 0) + { + value = default; + return false; + } + + Debug.Assert(parseData.Year <= 9999); // All of our callers to date parse the year from fixed 4-digit fields so this value is trusted. + + if ((uint)parseData.Month - 1 >= 12) + { + value = default; + return false; + } + + uint dayMinusOne = (uint)parseData.Day - 1; + if ( + dayMinusOne >= 28 + && dayMinusOne >= DateTime.DaysInMonth(parseData.Year, parseData.Month) + ) + { + value = default; + return false; + } + + if ((uint)parseData.Hour > 23) + { + value = default; + return false; + } + + if ((uint)parseData.Minute > 59) + { + value = default; + return false; + } + + // This needs to allow leap seconds when appropriate. + // See https://github.com/dotnet/runtime/issues/30135. + if ((uint)parseData.Second > 59) + { + value = default; + return false; + } + + Debug.Assert(parseData.Fraction is >= 0 and <= JsonConstants.MaxDateTimeFraction); // All of our callers to date parse the fraction from fixed 7-digit fields so this value is trusted. + + ReadOnlySpan days = DateTime.IsLeapYear(parseData.Year) + ? DaysToMonth366 + : DaysToMonth365; + int yearMinusOne = parseData.Year - 1; + int totalDays = + yearMinusOne * 365 + + yearMinusOne / 4 + - yearMinusOne / 100 + + yearMinusOne / 400 + + days[parseData.Month - 1] + + parseData.Day + - 1; + long ticks = totalDays * TimeSpan.TicksPerDay; + int totalSeconds = parseData.Hour * 3600 + parseData.Minute * 60 + parseData.Second; + ticks += totalSeconds * TimeSpan.TicksPerSecond; + ticks += parseData.Fraction; + value = new DateTime(ticks: ticks, kind: kind); + return true; + } + + private static ReadOnlySpan DaysToMonth365 => + [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334, 365]; + private static ReadOnlySpan DaysToMonth366 => + [0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335, 366]; + } + + internal static class ThrowHelper + { + private const string ExceptionSourceValueToRethrowAsJsonException = + "System.Text.Json.Rethrowable"; + + [DoesNotReturn] + public static void ThrowInvalidOperationException_ExpectedString(JsonTokenType tokenType) + { + throw GetInvalidOperationException("string", tokenType); + } + + public static void ThrowFormatException(DataType dataType) + { + throw new FormatException(SR.Format(SR.UnsupportedFormat, dataType)) + { + Source = ExceptionSourceValueToRethrowAsJsonException, + }; + } + + private static global::System.Exception GetInvalidOperationException( + string message, + JsonTokenType tokenType + ) + { + return GetInvalidOperationException(SR.Format(SR.InvalidCast, tokenType, message)); + } + + private static InvalidOperationException GetInvalidOperationException(string message) + { + return new InvalidOperationException(message) + { + Source = ExceptionSourceValueToRethrowAsJsonException, + }; + } + } + + internal static class Utf8JsonReaderExtensions + { + internal static int ValueLength(this Utf8JsonReader reader) => + reader.HasValueSequence + ? checked((int)reader.ValueSequence.Length) + : reader.ValueSpan.Length; + } + + internal enum DataType + { + TimeOnly, + DateOnly, + } + + [SuppressMessage("ReSharper", "InconsistentNaming")] + internal static class SR + { + private static readonly bool s_usingResourceKeys = + AppContext.TryGetSwitch( + "System.Resources.UseSystemResourceKeys", + out bool usingResourceKeys + ) && usingResourceKeys; + + public static string UnsupportedFormat => Strings.UnsupportedFormat; + + public static string InvalidCast => Strings.InvalidCast; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static string Format(string resourceFormat, object? p1) => + s_usingResourceKeys + ? string.Join(", ", resourceFormat, p1) + : string.Format(resourceFormat, p1); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static string Format(string resourceFormat, object? p1, object? p2) => + s_usingResourceKeys + ? string.Join(", ", resourceFormat, p1, p2) + : string.Format(resourceFormat, p1, p2); + } + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute( + "System.Resources.Tools.StronglyTypedResourceBuilder", + "17.0.0.0" + )] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Strings + { + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute( + "Microsoft.Performance", + "CA1811:AvoidUncalledPrivateCode" + )] + internal Strings() { } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute( + global::System.ComponentModel.EditorBrowsableState.Advanced + )] + internal static global::System.Resources.ResourceManager ResourceManager + { + get + { + if (object.ReferenceEquals(resourceMan, null)) + { + global::System.Resources.ResourceManager temp = + new global::System.Resources.ResourceManager( + "System.Text.Json.Resources.Strings", + typeof(Strings).Assembly + ); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute( + global::System.ComponentModel.EditorBrowsableState.Advanced + )] + internal static global::System.Globalization.CultureInfo Culture + { + get { return resourceCulture; } + set { resourceCulture = value; } + } + + /// + /// Looks up a localized string similar to Cannot get the value of a token type '{0}' as a {1}.. + /// + internal static string InvalidCast + { + get { return ResourceManager.GetString("InvalidCast", resourceCulture); } + } + + /// + /// Looks up a localized string similar to The JSON value is not in a supported {0} format.. + /// + internal static string UnsupportedFormat + { + get { return ResourceManager.GetString("UnsupportedFormat", resourceCulture); } + } + } +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/DateTimeSerializer.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/DateTimeSerializer.cs new file mode 100644 index 000000000000..561fca0603b3 --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/DateTimeSerializer.cs @@ -0,0 +1,22 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedNullable.Core; + +internal class DateTimeSerializer : JsonConverter +{ + public override DateTime Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + return DateTime.Parse(reader.GetString()!, null, DateTimeStyles.RoundtripKind); + } + + public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString(Constants.DateTimeFormat)); + } +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/EmptyRequest.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/EmptyRequest.cs new file mode 100644 index 000000000000..95a2fbc72be2 --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/EmptyRequest.cs @@ -0,0 +1,11 @@ +using System.Net.Http; + +namespace SeedNullable.Core; + +/// +/// The request object to send without a request body. +/// +internal record EmptyRequest : BaseRequest +{ + internal override HttpContent? CreateContent() => null; +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/EncodingCache.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/EncodingCache.cs new file mode 100644 index 000000000000..ace24561595f --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/EncodingCache.cs @@ -0,0 +1,11 @@ +using System.Text; + +namespace SeedNullable.Core; + +internal static class EncodingCache +{ + internal static readonly Encoding Utf8NoBom = new UTF8Encoding( + encoderShouldEmitUTF8Identifier: false, + throwOnInvalidBytes: true + ); +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/Extensions.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/Extensions.cs new file mode 100644 index 000000000000..1381fd8f3f5c --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/Extensions.cs @@ -0,0 +1,55 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.Serialization; + +namespace SeedNullable.Core; + +internal static class Extensions +{ + public static string Stringify(this Enum value) + { + var field = value.GetType().GetField(value.ToString()); + if (field != null) + { + var attribute = (EnumMemberAttribute?) + global::System.Attribute.GetCustomAttribute(field, typeof(EnumMemberAttribute)); + return attribute?.Value ?? value.ToString(); + } + return value.ToString(); + } + + /// + /// Asserts that a condition is true, throwing an exception with the specified message if it is false. + /// + /// The condition to assert. + /// The exception message if the assertion fails. + /// Thrown when the condition is false. + internal static void Assert(this object value, bool condition, string message) + { + if (!condition) + { + throw new global::System.Exception(message); + } + } + + /// + /// Asserts that a value is not null, throwing an exception with the specified message if it is null. + /// + /// The type of the value to assert. + /// The value to assert is not null. + /// The exception message if the assertion fails. + /// The non-null value. + /// Thrown when the value is null. + internal static TValue Assert( + this object _unused, + [NotNull] TValue? value, + string message + ) + where TValue : class + { + if (value == null) + { + throw new global::System.Exception(message); + } + return value; + } +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/FormUrlEncoder.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/FormUrlEncoder.cs new file mode 100644 index 000000000000..b42a4d866c97 --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/FormUrlEncoder.cs @@ -0,0 +1,33 @@ +using global::System.Net.Http; + +namespace SeedNullable.Core; + +/// +/// Encodes an object into a form URL-encoded content. +/// +public static class FormUrlEncoder +{ + /// + /// Encodes an object into a form URL-encoded content using Deep Object notation. + /// + /// Object to form URL-encode. You can pass in an object or dictionary, but not lists, strings, or primitives. + /// Throws when passing in a list, a string, or a primitive value. + internal static FormUrlEncodedContent EncodeAsDeepObject(object value) => + new(QueryStringConverter.ToDeepObject(value)); + + /// + /// Encodes an object into a form URL-encoded content using Exploded Form notation. + /// + /// Object to form URL-encode. You can pass in an object or dictionary, but not lists, strings, or primitives. + /// Throws when passing in a list, a string, or a primitive value. + internal static FormUrlEncodedContent EncodeAsExplodedForm(object value) => + new(QueryStringConverter.ToExplodedForm(value)); + + /// + /// Encodes an object into a form URL-encoded content using Form notation without exploding parameters. + /// + /// Object to form URL-encode. You can pass in an object or dictionary, but not lists, strings, or primitives. + /// Throws when passing in a list, a string, or a primitive value. + internal static FormUrlEncodedContent EncodeAsForm(object value) => + new(QueryStringConverter.ToForm(value)); +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/HeaderValue.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/HeaderValue.cs new file mode 100644 index 000000000000..2a25d8379f92 --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/HeaderValue.cs @@ -0,0 +1,41 @@ +using OneOf; + +namespace SeedNullable.Core; + +internal sealed class HeaderValue( + OneOf< + string, + Func, + Func>, + Func> + > value +) + : OneOfBase< + string, + Func, + Func>, + Func> + >(value) +{ + public static implicit operator HeaderValue(string value) => new(value); + + public static implicit operator HeaderValue(Func value) => new(value); + + public static implicit operator HeaderValue( + Func> value + ) => new(value); + + public static implicit operator HeaderValue( + Func> value + ) => new(value); + + internal global::System.Threading.Tasks.ValueTask ResolveAsync() + { + return Match( + str => new global::System.Threading.Tasks.ValueTask(str), + syncFunc => new global::System.Threading.Tasks.ValueTask(syncFunc()), + valueTaskFunc => valueTaskFunc(), + taskFunc => new global::System.Threading.Tasks.ValueTask(taskFunc()) + ); + } +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/Headers.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/Headers.cs new file mode 100644 index 000000000000..f9c0ba1c69fa --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/Headers.cs @@ -0,0 +1,28 @@ +namespace SeedNullable.Core; + +/// +/// Represents the headers sent with the request. +/// +internal sealed class Headers : Dictionary +{ + internal Headers() { } + + /// + /// Initializes a new instance of the Headers class with the specified value. + /// + /// + internal Headers(Dictionary value) + { + foreach (var kvp in value) + { + this[kvp.Key] = new HeaderValue(kvp.Value); + } + } + + /// + /// Initializes a new instance of the Headers class with the specified value. + /// + /// + internal Headers(IEnumerable> value) + : base(value.ToDictionary(e => e.Key, e => e.Value)) { } +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/HttpMethodExtensions.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/HttpMethodExtensions.cs new file mode 100644 index 000000000000..6fffe854b76e --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/HttpMethodExtensions.cs @@ -0,0 +1,8 @@ +using System.Net.Http; + +namespace SeedNullable.Core; + +internal static class HttpMethodExtensions +{ + public static readonly HttpMethod Patch = new("PATCH"); +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/IIsRetryableContent.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/IIsRetryableContent.cs new file mode 100644 index 000000000000..dff1dfed4ca8 --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/IIsRetryableContent.cs @@ -0,0 +1,6 @@ +namespace SeedNullable.Core; + +public interface IIsRetryableContent +{ + public bool IsRetryable { get; } +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/IRequestOptions.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/IRequestOptions.cs new file mode 100644 index 000000000000..2c5a4e662eab --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/IRequestOptions.cs @@ -0,0 +1,88 @@ +namespace SeedNullable.Core; + +internal interface IRequestOptions +{ + /// + /// The Base URL for the API. + /// + public string? BaseUrl { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } + + /// + /// The http client used to make requests. + /// + public HttpClient? HttpClient { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } + + /// + /// The http headers sent with the request. + /// + internal Headers Headers { get; init; } + + /// + /// Additional headers to be sent with the request. + /// Headers previously set with matching keys will be overwritten. + /// + public IEnumerable> AdditionalHeaders { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } + + /// + /// The http client used to make requests. + /// + public int? MaxRetries { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } + + /// + /// The timeout for the request. + /// + public TimeSpan? Timeout { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } + + /// + /// Additional query parameters sent with the request. + /// + public IEnumerable> AdditionalQueryParameters { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } + + /// + /// Additional body properties sent with the request. + /// This is only applied to JSON requests. + /// + public object? AdditionalBodyProperties { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/JsonAccessAttribute.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/JsonAccessAttribute.cs new file mode 100644 index 000000000000..31ec8addbaf9 --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/JsonAccessAttribute.cs @@ -0,0 +1,15 @@ +namespace SeedNullable.Core; + +[global::System.AttributeUsage( + global::System.AttributeTargets.Property | global::System.AttributeTargets.Field +)] +internal class JsonAccessAttribute(JsonAccessType accessType) : global::System.Attribute +{ + internal JsonAccessType AccessType { get; init; } = accessType; +} + +internal enum JsonAccessType +{ + ReadOnly, + WriteOnly, +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/JsonConfiguration.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/JsonConfiguration.cs new file mode 100644 index 000000000000..3ce6ded218cb --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/JsonConfiguration.cs @@ -0,0 +1,251 @@ +using global::System.Reflection; +using global::System.Text.Json; +using global::System.Text.Json.Nodes; +using global::System.Text.Json.Serialization; +using global::System.Text.Json.Serialization.Metadata; + +namespace SeedNullable.Core; + +internal static partial class JsonOptions +{ + internal static readonly JsonSerializerOptions JsonSerializerOptions; + + static JsonOptions() + { + var options = new JsonSerializerOptions + { + Converters = + { + new DateTimeSerializer(), +#if USE_PORTABLE_DATE_ONLY + new DateOnlyConverter(), +#endif + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, +#if DEBUG + WriteIndented = true, +#endif + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = new DefaultJsonTypeInfoResolver + { + Modifiers = + { + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, + }, + }, + }; + ConfigureJsonSerializerOptions(options); + JsonSerializerOptions = options; + } + + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); +} + +internal static class JsonUtils +{ + internal static string Serialize(T obj) => + JsonSerializer.Serialize(obj, JsonOptions.JsonSerializerOptions); + + internal static JsonElement SerializeToElement(T obj) => + JsonSerializer.SerializeToElement(obj, JsonOptions.JsonSerializerOptions); + + internal static JsonDocument SerializeToDocument(T obj) => + JsonSerializer.SerializeToDocument(obj, JsonOptions.JsonSerializerOptions); + + internal static JsonNode? SerializeToNode(T obj) => + JsonSerializer.SerializeToNode(obj, JsonOptions.JsonSerializerOptions); + + internal static byte[] SerializeToUtf8Bytes(T obj) => + JsonSerializer.SerializeToUtf8Bytes(obj, JsonOptions.JsonSerializerOptions); + + internal static string SerializeWithAdditionalProperties( + T obj, + object? additionalProperties = null + ) + { + if (additionalProperties == null) + { + return Serialize(obj); + } + var additionalPropertiesJsonNode = SerializeToNode(additionalProperties); + if (additionalPropertiesJsonNode is not JsonObject additionalPropertiesJsonObject) + { + throw new InvalidOperationException( + "The additional properties must serialize to a JSON object." + ); + } + var jsonNode = SerializeToNode(obj); + if (jsonNode is not JsonObject jsonObject) + { + throw new InvalidOperationException( + "The serialized object must be a JSON object to add properties." + ); + } + MergeJsonObjects(jsonObject, additionalPropertiesJsonObject); + return jsonObject.ToJsonString(JsonOptions.JsonSerializerOptions); + } + + private static void MergeJsonObjects(JsonObject baseObject, JsonObject overrideObject) + { + foreach (var property in overrideObject) + { + if (!baseObject.TryGetPropertyValue(property.Key, out JsonNode? existingValue)) + { + baseObject[property.Key] = + property.Value != null ? JsonNode.Parse(property.Value.ToJsonString()) : null; + continue; + } + if ( + existingValue is JsonObject nestedBaseObject + && property.Value is JsonObject nestedOverrideObject + ) + { + // If both values are objects, recursively merge them. + MergeJsonObjects(nestedBaseObject, nestedOverrideObject); + continue; + } + // Otherwise, the overrideObject takes precedence. + baseObject[property.Key] = + property.Value != null ? JsonNode.Parse(property.Value.ToJsonString()) : null; + } + } + + internal static T Deserialize(string json) => + JsonSerializer.Deserialize(json, JsonOptions.JsonSerializerOptions)!; +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/JsonRequest.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/JsonRequest.cs new file mode 100644 index 000000000000..c9f98f5eb499 --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/JsonRequest.cs @@ -0,0 +1,36 @@ +using System.Net.Http; + +namespace SeedNullable.Core; + +/// +/// The request object to be sent for JSON APIs. +/// +internal record JsonRequest : BaseRequest +{ + internal object? Body { get; init; } + + internal override HttpContent? CreateContent() + { + if (Body is null && Options?.AdditionalBodyProperties is null) + { + return null; + } + + var (encoding, charset, mediaType) = ParseContentTypeOrDefault( + ContentType, + Utf8NoBom, + "application/json" + ); + var content = new StringContent( + JsonUtils.SerializeWithAdditionalProperties(Body, Options?.AdditionalBodyProperties), + encoding, + mediaType + ); + if (string.IsNullOrEmpty(charset) && content.Headers.ContentType is not null) + { + content.Headers.ContentType.CharSet = ""; + } + + return content; + } +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/MultipartFormRequest.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/MultipartFormRequest.cs new file mode 100644 index 000000000000..cf9fc39a7d33 --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/MultipartFormRequest.cs @@ -0,0 +1,294 @@ +using System.Net.Http; +using System.Net.Http.Headers; + +namespace SeedNullable.Core; + +/// +/// The request object to be sent for multipart form data. +/// +internal record MultipartFormRequest : BaseRequest +{ + private readonly List> _partAdders = []; + + internal void AddJsonPart(string name, object? value) => AddJsonPart(name, value, null); + + internal void AddJsonPart(string name, object? value, string? contentType) + { + if (value is null) + { + return; + } + + _partAdders.Add(form => + { + var (encoding, charset, mediaType) = ParseContentTypeOrDefault( + contentType, + Utf8NoBom, + "application/json" + ); + var content = new StringContent(JsonUtils.Serialize(value), encoding, mediaType); + if (string.IsNullOrEmpty(charset) && content.Headers.ContentType is not null) + { + content.Headers.ContentType.CharSet = ""; + } + + form.Add(content, name); + }); + } + + internal void AddJsonParts(string name, IEnumerable? value) => + AddJsonParts(name, value, null); + + internal void AddJsonParts(string name, IEnumerable? value, string? contentType) + { + if (value is null) + { + return; + } + + foreach (var item in value) + { + AddJsonPart(name, item, contentType); + } + } + + internal void AddJsonParts(string name, IEnumerable? value) => + AddJsonParts(name, value, null); + + internal void AddJsonParts(string name, IEnumerable? value, string? contentType) + { + if (value is null) + { + return; + } + + foreach (var item in value) + { + AddJsonPart(name, item, contentType); + } + } + + internal void AddStringPart(string name, object? value) => AddStringPart(name, value, null); + + internal void AddStringPart(string name, object? value, string? contentType) + { + if (value is null) + { + return; + } + + AddStringPart(name, ValueConvert.ToString(value), contentType); + } + + internal void AddStringPart(string name, string? value) => AddStringPart(name, value, null); + + internal void AddStringPart(string name, string? value, string? contentType) + { + if (value is null) + { + return; + } + + _partAdders.Add(form => + { + var (encoding, charset, mediaType) = ParseContentTypeOrDefault( + contentType, + Utf8NoBom, + "text/plain" + ); + var content = new StringContent(value, encoding, mediaType); + if (string.IsNullOrEmpty(charset) && content.Headers.ContentType is not null) + { + content.Headers.ContentType.CharSet = ""; + } + + form.Add(content, name); + }); + } + + internal void AddStringParts(string name, IEnumerable? value) => + AddStringParts(name, value, null); + + internal void AddStringParts(string name, IEnumerable? value, string? contentType) + { + if (value is null) + { + return; + } + + AddStringPart(name, ValueConvert.ToString(value), contentType); + } + + internal void AddStringParts(string name, IEnumerable? value) => + AddStringParts(name, value, null); + + internal void AddStringParts(string name, IEnumerable? value, string? contentType) + { + if (value is null) + { + return; + } + + foreach (var item in value) + { + AddStringPart(name, item, contentType); + } + } + + internal void AddStreamPart(string name, Stream? stream, string? fileName) => + AddStreamPart(name, stream, fileName, null); + + internal void AddStreamPart(string name, Stream? stream, string? fileName, string? contentType) + { + if (stream is null) + { + return; + } + + _partAdders.Add(form => + { + var content = new StreamContent(stream) + { + Headers = + { + ContentType = MediaTypeHeaderValue.Parse( + contentType ?? "application/octet-stream" + ), + }, + }; + + if (fileName is not null) + { + form.Add(content, name, fileName); + } + else + { + form.Add(content, name); + } + }); + } + + internal void AddFileParameterPart(string name, Stream? stream) => + AddStreamPart(name, stream, null, null); + + internal void AddFileParameterPart(string name, FileParameter? file) => + AddFileParameterPart(name, file, null); + + internal void AddFileParameterPart( + string name, + FileParameter? file, + string? fallbackContentType + ) => + AddStreamPart(name, file?.Stream, file?.FileName, file?.ContentType ?? fallbackContentType); + + internal void AddFileParameterParts(string name, IEnumerable? files) => + AddFileParameterParts(name, files, null); + + internal void AddFileParameterParts( + string name, + IEnumerable? files, + string? fallbackContentType + ) + { + if (files is null) + { + return; + } + + foreach (var file in files) + { + AddFileParameterPart(name, file, fallbackContentType); + } + } + + internal void AddFormEncodedPart(string name, object? value) => + AddFormEncodedPart(name, value, null); + + internal void AddFormEncodedPart(string name, object? value, string? contentType) + { + if (value is null) + { + return; + } + + _partAdders.Add(form => + { + var content = FormUrlEncoder.EncodeAsForm(value); + if (!string.IsNullOrEmpty(contentType)) + { + content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType); + } + + form.Add(content, name); + }); + } + + internal void AddFormEncodedParts(string name, IEnumerable? value) => + AddFormEncodedParts(name, value, null); + + internal void AddFormEncodedParts(string name, IEnumerable? value, string? contentType) + { + if (value is null) + { + return; + } + + foreach (var item in value) + { + AddFormEncodedPart(name, item, contentType); + } + } + + internal void AddExplodedFormEncodedPart(string name, object? value) => + AddExplodedFormEncodedPart(name, value, null); + + internal void AddExplodedFormEncodedPart(string name, object? value, string? contentType) + { + if (value is null) + { + return; + } + + _partAdders.Add(form => + { + var content = FormUrlEncoder.EncodeAsExplodedForm(value); + if (!string.IsNullOrEmpty(contentType)) + { + content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType); + } + + form.Add(content, name); + }); + } + + internal void AddExplodedFormEncodedParts(string name, IEnumerable? value) => + AddExplodedFormEncodedParts(name, value, null); + + internal void AddExplodedFormEncodedParts( + string name, + IEnumerable? value, + string? contentType + ) + { + if (value is null) + { + return; + } + + foreach (var item in value) + { + AddExplodedFormEncodedPart(name, item, contentType); + } + } + + internal override HttpContent CreateContent() + { + var form = new MultipartFormDataContent(); + foreach (var adder in _partAdders) + { + adder(form); + } + + return form; + } +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/NullableAttribute.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/NullableAttribute.cs new file mode 100644 index 000000000000..9d9f6631cb28 --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedNullable.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/OneOfSerializer.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/OneOfSerializer.cs new file mode 100644 index 000000000000..9e333b352e75 --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/OneOfSerializer.cs @@ -0,0 +1,91 @@ +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using OneOf; + +namespace SeedNullable.Core; + +internal class OneOfSerializer : JsonConverter +{ + public override IOneOf? Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType is JsonTokenType.Null) + return default; + + foreach (var (type, cast) in GetOneOfTypes(typeToConvert)) + { + try + { + var readerCopy = reader; + var result = JsonSerializer.Deserialize(ref readerCopy, type, options); + reader.Skip(); + return (IOneOf)cast.Invoke(null, [result])!; + } + catch (JsonException) { } + } + + throw new JsonException( + $"Cannot deserialize into one of the supported types for {typeToConvert}" + ); + } + + public override void Write(Utf8JsonWriter writer, IOneOf value, JsonSerializerOptions options) + { + JsonSerializer.Serialize(writer, value.Value, options); + } + + private static (global::System.Type type, MethodInfo cast)[] GetOneOfTypes( + global::System.Type typeToConvert + ) + { + var type = typeToConvert; + if (Nullable.GetUnderlyingType(type) is { } underlyingType) + { + type = underlyingType; + } + + var casts = type.GetRuntimeMethods() + .Where(m => m.IsSpecialName && m.Name == "op_Implicit") + .ToArray(); + while (type != null) + { + if ( + type.IsGenericType + && (type.Name.StartsWith("OneOf`") || type.Name.StartsWith("OneOfBase`")) + ) + { + var genericArguments = type.GetGenericArguments(); + if (genericArguments.Length == 1) + { + return [(genericArguments[0], casts[0])]; + } + + // if object type is present, make sure it is last + var indexOfObjectType = Array.IndexOf(genericArguments, typeof(object)); + if (indexOfObjectType != -1 && genericArguments.Length - 1 != indexOfObjectType) + { + genericArguments = genericArguments + .OrderBy(t => t == typeof(object) ? 1 : 0) + .ToArray(); + } + + return genericArguments + .Select(t => (t, casts.First(c => c.GetParameters()[0].ParameterType == t))) + .ToArray(); + } + + type = type.BaseType; + } + + throw new InvalidOperationException($"{type} isn't OneOf or OneOfBase"); + } + + public override bool CanConvert(global::System.Type typeToConvert) + { + return typeof(IOneOf).IsAssignableFrom(typeToConvert); + } +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/Optional.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/Optional.cs new file mode 100644 index 000000000000..8bb251ff6413 --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedNullable.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/OptionalAttribute.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..6fd2bd6b45b9 --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedNullable.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/Public/AdditionalProperties.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/Public/AdditionalProperties.cs new file mode 100644 index 000000000000..cca8259830c4 --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/Public/AdditionalProperties.cs @@ -0,0 +1,353 @@ +using global::System.Collections; +using global::System.Collections.ObjectModel; +using global::System.Text.Json; +using global::System.Text.Json.Nodes; +using SeedNullable.Core; + +namespace SeedNullable; + +public record ReadOnlyAdditionalProperties : ReadOnlyAdditionalProperties +{ + internal ReadOnlyAdditionalProperties() { } + + internal ReadOnlyAdditionalProperties(IDictionary properties) + : base(properties) { } +} + +public record ReadOnlyAdditionalProperties : IReadOnlyDictionary +{ + private readonly Dictionary _extensionData = new(); + private readonly Dictionary _convertedCache = new(); + + internal ReadOnlyAdditionalProperties() + { + _extensionData = new Dictionary(); + _convertedCache = new Dictionary(); + } + + internal ReadOnlyAdditionalProperties(IDictionary properties) + { + _extensionData = new Dictionary(properties.Count); + _convertedCache = new Dictionary(properties.Count); + foreach (var kvp in properties) + { + if (kvp.Value is JsonElement element) + { + _extensionData.Add(kvp.Key, element); + } + else + { + _extensionData[kvp.Key] = JsonUtils.SerializeToElement(kvp.Value); + } + + _convertedCache[kvp.Key] = kvp.Value; + } + } + + private static T ConvertToT(JsonElement value) + { + if (typeof(T) == typeof(JsonElement)) + { + return (T)(object)value; + } + + return value.Deserialize(JsonOptions.JsonSerializerOptions)!; + } + + internal void CopyFromExtensionData(IDictionary extensionData) + { + _extensionData.Clear(); + _convertedCache.Clear(); + foreach (var kvp in extensionData) + { + _extensionData[kvp.Key] = kvp.Value; + if (kvp.Value is T value) + { + _convertedCache[kvp.Key] = value; + } + } + } + + private T GetCached(string key) + { + if (_convertedCache.TryGetValue(key, out var cached)) + { + return cached; + } + + var value = ConvertToT(_extensionData[key]); + _convertedCache[key] = value; + return value; + } + + public IEnumerator> GetEnumerator() + { + return _extensionData + .Select(kvp => new KeyValuePair(kvp.Key, GetCached(kvp.Key))) + .GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + public int Count => _extensionData.Count; + + public bool ContainsKey(string key) => _extensionData.ContainsKey(key); + + public bool TryGetValue(string key, out T value) + { + if (_convertedCache.TryGetValue(key, out value!)) + { + return true; + } + + if (_extensionData.TryGetValue(key, out var element)) + { + value = ConvertToT(element); + _convertedCache[key] = value; + return true; + } + + return false; + } + + public T this[string key] => GetCached(key); + + public IEnumerable Keys => _extensionData.Keys; + + public IEnumerable Values => Keys.Select(GetCached); +} + +public record AdditionalProperties : AdditionalProperties +{ + public AdditionalProperties() { } + + public AdditionalProperties(IDictionary properties) + : base(properties) { } +} + +public record AdditionalProperties : IDictionary +{ + private readonly Dictionary _extensionData; + private readonly Dictionary _convertedCache; + + public AdditionalProperties() + { + _extensionData = new Dictionary(); + _convertedCache = new Dictionary(); + } + + public AdditionalProperties(IDictionary properties) + { + _extensionData = new Dictionary(properties.Count); + _convertedCache = new Dictionary(properties.Count); + foreach (var kvp in properties) + { + _extensionData[kvp.Key] = kvp.Value; + _convertedCache[kvp.Key] = kvp.Value; + } + } + + private static T ConvertToT(object? extensionDataValue) + { + return extensionDataValue switch + { + T value => value, + JsonElement jsonElement => jsonElement.Deserialize( + JsonOptions.JsonSerializerOptions + )!, + JsonNode jsonNode => jsonNode.Deserialize(JsonOptions.JsonSerializerOptions)!, + _ => JsonUtils + .SerializeToElement(extensionDataValue) + .Deserialize(JsonOptions.JsonSerializerOptions)!, + }; + } + + internal void CopyFromExtensionData(IDictionary extensionData) + { + _extensionData.Clear(); + _convertedCache.Clear(); + foreach (var kvp in extensionData) + { + _extensionData[kvp.Key] = kvp.Value; + if (kvp.Value is T value) + { + _convertedCache[kvp.Key] = value; + } + } + } + + internal void CopyToExtensionData(IDictionary extensionData) + { + extensionData.Clear(); + foreach (var kvp in _extensionData) + { + extensionData[kvp.Key] = kvp.Value; + } + } + + public JsonObject ToJsonObject() => + ( + JsonUtils.SerializeToNode(_extensionData) + ?? throw new InvalidOperationException( + "Failed to serialize AdditionalProperties to JSON Node." + ) + ).AsObject(); + + public JsonNode ToJsonNode() => + JsonUtils.SerializeToNode(_extensionData) + ?? throw new InvalidOperationException( + "Failed to serialize AdditionalProperties to JSON Node." + ); + + public JsonElement ToJsonElement() => JsonUtils.SerializeToElement(_extensionData); + + public JsonDocument ToJsonDocument() => JsonUtils.SerializeToDocument(_extensionData); + + public IReadOnlyDictionary ToJsonElementDictionary() + { + return new ReadOnlyDictionary( + _extensionData.ToDictionary( + kvp => kvp.Key, + kvp => + { + if (kvp.Value is JsonElement jsonElement) + { + return jsonElement; + } + + return JsonUtils.SerializeToElement(kvp.Value); + } + ) + ); + } + + public ICollection Keys => _extensionData.Keys; + + public ICollection Values + { + get + { + var values = new T[_extensionData.Count]; + var i = 0; + foreach (var key in Keys) + { + values[i++] = GetCached(key); + } + + return values; + } + } + + private T GetCached(string key) + { + if (_convertedCache.TryGetValue(key, out var value)) + { + return value; + } + + value = ConvertToT(_extensionData[key]); + _convertedCache.Add(key, value); + return value; + } + + private void SetCached(string key, T value) + { + _extensionData[key] = value; + _convertedCache[key] = value; + } + + private void AddCached(string key, T value) + { + _extensionData.Add(key, value); + _convertedCache.Add(key, value); + } + + private bool RemoveCached(string key) + { + var isRemoved = _extensionData.Remove(key); + _convertedCache.Remove(key); + return isRemoved; + } + + public int Count => _extensionData.Count; + public bool IsReadOnly => false; + + public T this[string key] + { + get => GetCached(key); + set => SetCached(key, value); + } + + public void Add(string key, T value) => AddCached(key, value); + + public void Add(KeyValuePair item) => AddCached(item.Key, item.Value); + + public bool Remove(string key) => RemoveCached(key); + + public bool Remove(KeyValuePair item) => RemoveCached(item.Key); + + public bool ContainsKey(string key) => _extensionData.ContainsKey(key); + + public bool Contains(KeyValuePair item) + { + return _extensionData.ContainsKey(item.Key) + && EqualityComparer.Default.Equals(GetCached(item.Key), item.Value); + } + + public bool TryGetValue(string key, out T value) + { + if (_convertedCache.TryGetValue(key, out value!)) + { + return true; + } + + if (_extensionData.TryGetValue(key, out var extensionDataValue)) + { + value = ConvertToT(extensionDataValue); + _convertedCache[key] = value; + return true; + } + + return false; + } + + public void Clear() + { + _extensionData.Clear(); + _convertedCache.Clear(); + } + + public void CopyTo(KeyValuePair[] array, int arrayIndex) + { + if (array is null) + { + throw new ArgumentNullException(nameof(array)); + } + + if (arrayIndex < 0 || arrayIndex > array.Length) + { + throw new ArgumentOutOfRangeException(nameof(arrayIndex)); + } + + if (array.Length - arrayIndex < _extensionData.Count) + { + throw new ArgumentException( + "The array does not have enough space to copy the elements." + ); + } + + foreach (var kvp in _extensionData) + { + array[arrayIndex++] = new KeyValuePair(kvp.Key, GetCached(kvp.Key)); + } + } + + public IEnumerator> GetEnumerator() + { + return _extensionData + .Select(kvp => new KeyValuePair(kvp.Key, GetCached(kvp.Key))) + .GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/Public/ClientOptions.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/Public/ClientOptions.cs new file mode 100644 index 000000000000..d4ca98560574 --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/Public/ClientOptions.cs @@ -0,0 +1,83 @@ +using SeedNullable.Core; + +namespace SeedNullable; + +[Serializable] +public partial class ClientOptions +{ + /// + /// The http headers sent with the request. + /// + internal Headers Headers { get; init; } = new(); + + /// + /// The Base URL for the API. + /// + public string BaseUrl { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } = ""; + + /// + /// The http client used to make requests. + /// + public HttpClient HttpClient { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } = new HttpClient(); + + /// + /// Additional headers to be sent with HTTP requests. + /// Headers with matching keys will be overwritten by headers set on the request. + /// + public IEnumerable> AdditionalHeaders { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } = []; + + /// + /// The http client used to make requests. + /// + public int MaxRetries { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } = 2; + + /// + /// The timeout for the request. + /// + public TimeSpan Timeout { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } = TimeSpan.FromSeconds(30); + + /// + /// Clones this and returns a new instance + /// + internal ClientOptions Clone() + { + return new ClientOptions + { + BaseUrl = BaseUrl, + HttpClient = HttpClient, + MaxRetries = MaxRetries, + Timeout = Timeout, + Headers = new Headers(new Dictionary(Headers)), + }; + } +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/Public/FileParameter.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/Public/FileParameter.cs new file mode 100644 index 000000000000..aca9ac913cf2 --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/Public/FileParameter.cs @@ -0,0 +1,63 @@ +namespace SeedNullable; + +/// +/// File parameter for uploading files. +/// +public record FileParameter : IDisposable +#if NET6_0_OR_GREATER + , IAsyncDisposable +#endif +{ + private bool _disposed; + + /// + /// The name of the file to be uploaded. + /// + public string? FileName { get; set; } + + /// + /// The content type of the file to be uploaded. + /// + public string? ContentType { get; set; } + + /// + /// The content of the file to be uploaded. + /// + public required Stream Stream { get; set; } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + protected virtual void Dispose(bool disposing) + { + if (_disposed) + return; + if (disposing) + { + Stream.Dispose(); + } + + _disposed = true; + } + +#if NET6_0_OR_GREATER + /// + public async ValueTask DisposeAsync() + { + if (!_disposed) + { + await Stream.DisposeAsync().ConfigureAwait(false); + _disposed = true; + } + + GC.SuppressFinalize(this); + } +#endif + + public static implicit operator FileParameter(Stream stream) => new() { Stream = stream }; +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/Public/RequestOptions.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/Public/RequestOptions.cs new file mode 100644 index 000000000000..d7ec4cd7216b --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/Public/RequestOptions.cs @@ -0,0 +1,91 @@ +using SeedNullable.Core; + +namespace SeedNullable; + +[Serializable] +public partial class RequestOptions : IRequestOptions +{ + /// + /// The http headers sent with the request. + /// + Headers IRequestOptions.Headers { get; init; } = new(); + + /// + /// The Base URL for the API. + /// + public string? BaseUrl { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } + + /// + /// The http client used to make requests. + /// + public HttpClient? HttpClient { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } + + /// + /// Additional headers to be sent with the request. + /// Headers previously set with matching keys will be overwritten. + /// + public IEnumerable> AdditionalHeaders { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } = []; + + /// + /// The http client used to make requests. + /// + public int? MaxRetries { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } + + /// + /// The timeout for the request. + /// + public TimeSpan? Timeout { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } + + /// + /// Additional query parameters sent with the request. + /// + public IEnumerable> AdditionalQueryParameters { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } = Enumerable.Empty>(); + + /// + /// Additional body properties sent with the request. + /// This is only applied to JSON requests. + /// + public object? AdditionalBodyProperties { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/Public/SeedNullableApiException.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/Public/SeedNullableApiException.cs new file mode 100644 index 000000000000..df34e6dd55ad --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/Public/SeedNullableApiException.cs @@ -0,0 +1,18 @@ +namespace SeedNullable; + +/// +/// This exception type will be thrown for any non-2XX API responses. +/// +public class SeedNullableApiException(string message, int statusCode, object body) + : SeedNullableException(message) +{ + /// + /// The error code of the response that triggered the exception. + /// + public int StatusCode => statusCode; + + /// + /// The body of the response that triggered the exception. + /// + public object Body => body; +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/Public/SeedNullableException.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/Public/SeedNullableException.cs new file mode 100644 index 000000000000..98e32eeb07ee --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/Public/SeedNullableException.cs @@ -0,0 +1,7 @@ +namespace SeedNullable; + +/// +/// Base exception class for all exceptions thrown by the SDK. +/// +public class SeedNullableException(string message, Exception? innerException = null) + : Exception(message, innerException); diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/Public/Version.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/Public/Version.cs new file mode 100644 index 000000000000..0fd78899c904 --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/Public/Version.cs @@ -0,0 +1,7 @@ +namespace SeedNullable; + +[Serializable] +internal class Version +{ + public const string Current = "0.0.1"; +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/QueryStringConverter.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/QueryStringConverter.cs new file mode 100644 index 000000000000..481f9dd7c53a --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/QueryStringConverter.cs @@ -0,0 +1,229 @@ +using global::System.Text.Json; + +namespace SeedNullable.Core; + +/// +/// Converts an object into a query string collection. +/// +internal static class QueryStringConverter +{ + /// + /// Converts an object into a query string collection using Deep Object notation. + /// + /// Object to form URL-encode. You can pass in an object or dictionary, but not lists, strings, or primitives. + /// Throws when passing in a list, a string, or a primitive value. + /// A collection of key value pairs. The keys and values are not URL encoded. + internal static IEnumerable> ToDeepObject(object value) + { + var queryCollection = new List>(); + var json = JsonUtils.SerializeToElement(value); + AssertRootJson(json); + JsonToDeepObject(json, "", queryCollection); + return queryCollection; + } + + /// + /// Converts an object into a query string collection using Exploded Form notation. + /// + /// Object to form URL-encode. You can pass in an object or dictionary, but not lists, strings, or primitives. + /// Throws when passing in a list, a string, or a primitive value. + /// A collection of key value pairs. The keys and values are not URL encoded. + internal static IEnumerable> ToExplodedForm(object value) + { + var queryCollection = new List>(); + var json = JsonUtils.SerializeToElement(value); + AssertRootJson(json); + JsonToFormExploded(json, "", queryCollection); + return queryCollection; + } + + /// + /// Converts an object into a query string collection using Form notation without exploding parameters. + /// + /// Object to form URL-encode. You can pass in an object or dictionary, but not lists, strings, or primitives. + /// Throws when passing in a list, a string, or a primitive value. + /// A collection of key value pairs. The keys and values are not URL encoded. + internal static IEnumerable> ToForm(object value) + { + var queryCollection = new List>(); + var json = JsonUtils.SerializeToElement(value); + AssertRootJson(json); + JsonToForm(json, "", queryCollection); + return queryCollection; + } + + private static void AssertRootJson(JsonElement json) + { + switch (json.ValueKind) + { + case JsonValueKind.Object: + break; + case JsonValueKind.Array: + case JsonValueKind.Undefined: + case JsonValueKind.String: + case JsonValueKind.Number: + case JsonValueKind.True: + case JsonValueKind.False: + case JsonValueKind.Null: + default: + throw new global::System.Exception( + $"Only objects can be converted to query string collections. Given type is {json.ValueKind}." + ); + } + } + + private static void JsonToForm( + JsonElement element, + string prefix, + List> parameters + ) + { + switch (element.ValueKind) + { + case JsonValueKind.Object: + foreach (var property in element.EnumerateObject()) + { + var newPrefix = string.IsNullOrEmpty(prefix) + ? property.Name + : $"{prefix}[{property.Name}]"; + + JsonToForm(property.Value, newPrefix, parameters); + } + break; + case JsonValueKind.Array: + var arrayValues = element.EnumerateArray().Select(ValueToString).ToArray(); + parameters.Add( + new KeyValuePair(prefix, string.Join(",", arrayValues)) + ); + break; + case JsonValueKind.Null: + break; + case JsonValueKind.Undefined: + case JsonValueKind.String: + case JsonValueKind.Number: + case JsonValueKind.True: + case JsonValueKind.False: + default: + parameters.Add(new KeyValuePair(prefix, ValueToString(element))); + break; + } + } + + private static void JsonToFormExploded( + JsonElement element, + string prefix, + List> parameters + ) + { + switch (element.ValueKind) + { + case JsonValueKind.Object: + foreach (var property in element.EnumerateObject()) + { + var newPrefix = string.IsNullOrEmpty(prefix) + ? property.Name + : $"{prefix}[{property.Name}]"; + + JsonToFormExploded(property.Value, newPrefix, parameters); + } + + break; + case JsonValueKind.Array: + foreach (var item in element.EnumerateArray()) + { + if ( + item.ValueKind != JsonValueKind.Object + && item.ValueKind != JsonValueKind.Array + ) + { + parameters.Add( + new KeyValuePair(prefix, ValueToString(item)) + ); + } + else + { + JsonToFormExploded(item, prefix, parameters); + } + } + + break; + case JsonValueKind.Null: + break; + case JsonValueKind.Undefined: + case JsonValueKind.String: + case JsonValueKind.Number: + case JsonValueKind.True: + case JsonValueKind.False: + default: + parameters.Add(new KeyValuePair(prefix, ValueToString(element))); + break; + } + } + + private static void JsonToDeepObject( + JsonElement element, + string prefix, + List> parameters + ) + { + switch (element.ValueKind) + { + case JsonValueKind.Object: + foreach (var property in element.EnumerateObject()) + { + var newPrefix = string.IsNullOrEmpty(prefix) + ? property.Name + : $"{prefix}[{property.Name}]"; + + JsonToDeepObject(property.Value, newPrefix, parameters); + } + + break; + case JsonValueKind.Array: + var index = 0; + foreach (var item in element.EnumerateArray()) + { + var newPrefix = $"{prefix}[{index++}]"; + + if ( + item.ValueKind != JsonValueKind.Object + && item.ValueKind != JsonValueKind.Array + ) + { + parameters.Add( + new KeyValuePair(newPrefix, ValueToString(item)) + ); + } + else + { + JsonToDeepObject(item, newPrefix, parameters); + } + } + + break; + case JsonValueKind.Null: + break; + case JsonValueKind.Undefined: + case JsonValueKind.String: + case JsonValueKind.Number: + case JsonValueKind.True: + case JsonValueKind.False: + default: + parameters.Add(new KeyValuePair(prefix, ValueToString(element))); + break; + } + } + + private static string ValueToString(JsonElement element) + { + return element.ValueKind switch + { + JsonValueKind.String => element.GetString() ?? "", + JsonValueKind.Number => element.GetRawText(), + JsonValueKind.True => "true", + JsonValueKind.False => "false", + JsonValueKind.Null => "", + _ => element.GetRawText(), + }; + } +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/RawClient.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/RawClient.cs new file mode 100644 index 000000000000..bdb5b3542747 --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/RawClient.cs @@ -0,0 +1,506 @@ +using global::System.Net.Http; +using global::System.Net.Http.Headers; +using global::System.Text; +using SystemTask = global::System.Threading.Tasks.Task; + +namespace SeedNullable.Core; + +/// +/// Utility class for making raw HTTP requests to the API. +/// +internal partial class RawClient(ClientOptions clientOptions) +{ + private const int MaxRetryDelayMs = 60000; + private const double JitterFactor = 0.2; +#if NET6_0_OR_GREATER + // Use Random.Shared for thread-safe random number generation on .NET 6+ +#else + private static readonly object JitterLock = new(); + private static readonly Random JitterRandom = new(); +#endif + internal int BaseRetryDelay { get; set; } = 1000; + + /// + /// The client options applied on every request. + /// + internal readonly ClientOptions Options = clientOptions; + + [Obsolete("Use SendRequestAsync instead.")] + internal global::System.Threading.Tasks.Task MakeRequestAsync( + global::SeedNullable.Core.BaseRequest request, + CancellationToken cancellationToken = default + ) + { + return SendRequestAsync(request, cancellationToken); + } + + internal async global::System.Threading.Tasks.Task SendRequestAsync( + global::SeedNullable.Core.BaseRequest request, + CancellationToken cancellationToken = default + ) + { + // Apply the request timeout. + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var timeout = request.Options?.Timeout ?? Options.Timeout; + cts.CancelAfter(timeout); + + var httpRequest = await CreateHttpRequestAsync(request).ConfigureAwait(false); + // Send the request. + return await SendWithRetriesAsync(httpRequest, request.Options, cts.Token) + .ConfigureAwait(false); + } + + internal async global::System.Threading.Tasks.Task SendRequestAsync( + HttpRequestMessage request, + IRequestOptions? options, + CancellationToken cancellationToken = default + ) + { + // Apply the request timeout. + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var timeout = options?.Timeout ?? Options.Timeout; + cts.CancelAfter(timeout); + + // Send the request. + return await SendWithRetriesAsync(request, options, cts.Token).ConfigureAwait(false); + } + + private static async global::System.Threading.Tasks.Task CloneRequestAsync( + HttpRequestMessage request + ) + { + var clonedRequest = new HttpRequestMessage(request.Method, request.RequestUri); + clonedRequest.Version = request.Version; + switch (request.Content) + { + case MultipartContent oldMultipartFormContent: + var originalBoundary = + oldMultipartFormContent + .Headers.ContentType?.Parameters.First(p => + p.Name.Equals("boundary", StringComparison.OrdinalIgnoreCase) + ) + .Value?.Trim('"') ?? Guid.NewGuid().ToString(); + var newMultipartContent = oldMultipartFormContent switch + { + MultipartFormDataContent => new MultipartFormDataContent(originalBoundary), + _ => new MultipartContent(), + }; + foreach (var content in oldMultipartFormContent) + { + var ms = new MemoryStream(); + await content.CopyToAsync(ms).ConfigureAwait(false); + ms.Position = 0; + var newPart = new StreamContent(ms); + foreach (var header in oldMultipartFormContent.Headers) + { + newPart.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + + newMultipartContent.Add(newPart); + } + + clonedRequest.Content = newMultipartContent; + break; + default: + clonedRequest.Content = request.Content; + break; + } + + foreach (var header in request.Headers) + { + clonedRequest.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + + return clonedRequest; + } + + /// + /// Sends the request with retries, unless the request content is not retryable, + /// such as stream requests and multipart form data with stream content. + /// + private async global::System.Threading.Tasks.Task SendWithRetriesAsync( + HttpRequestMessage request, + IRequestOptions? options, + CancellationToken cancellationToken + ) + { + var httpClient = options?.HttpClient ?? Options.HttpClient; + var maxRetries = options?.MaxRetries ?? Options.MaxRetries; + var response = await httpClient + .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); + var isRetryableContent = IsRetryableContent(request); + + if (!isRetryableContent) + { + return new global::SeedNullable.Core.ApiResponse + { + StatusCode = (int)response.StatusCode, + Raw = response, + }; + } + + for (var i = 0; i < maxRetries; i++) + { + if (!ShouldRetry(response)) + { + break; + } + + var delayMs = GetRetryDelayFromHeaders(response, i); + await SystemTask.Delay(delayMs, cancellationToken).ConfigureAwait(false); + using var retryRequest = await CloneRequestAsync(request).ConfigureAwait(false); + response = await httpClient + .SendAsync( + retryRequest, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken + ) + .ConfigureAwait(false); + } + + return new global::SeedNullable.Core.ApiResponse + { + StatusCode = (int)response.StatusCode, + Raw = response, + }; + } + + private static bool ShouldRetry(HttpResponseMessage response) + { + var statusCode = (int)response.StatusCode; + return statusCode is 408 or 429 or >= 500; + } + + private static int AddPositiveJitter(int delayMs) + { +#if NET6_0_OR_GREATER + var random = Random.Shared.NextDouble(); +#else + double random; + lock (JitterLock) + { + random = JitterRandom.NextDouble(); + } +#endif + var jitterMultiplier = 1 + random * JitterFactor; + return (int)(delayMs * jitterMultiplier); + } + + private static int AddSymmetricJitter(int delayMs) + { +#if NET6_0_OR_GREATER + var random = Random.Shared.NextDouble(); +#else + double random; + lock (JitterLock) + { + random = JitterRandom.NextDouble(); + } +#endif + var jitterMultiplier = 1 + (random - 0.5) * JitterFactor; + return (int)(delayMs * jitterMultiplier); + } + + private int GetRetryDelayFromHeaders(HttpResponseMessage response, int retryAttempt) + { + if (response.Headers.TryGetValues("Retry-After", out var retryAfterValues)) + { + var retryAfter = retryAfterValues.FirstOrDefault(); + if (!string.IsNullOrEmpty(retryAfter)) + { + if (int.TryParse(retryAfter, out var retryAfterSeconds) && retryAfterSeconds > 0) + { + return Math.Min(retryAfterSeconds * 1000, MaxRetryDelayMs); + } + + if (DateTimeOffset.TryParse(retryAfter, out var retryAfterDate)) + { + var delay = (int)(retryAfterDate - DateTimeOffset.UtcNow).TotalMilliseconds; + if (delay > 0) + { + return Math.Min(delay, MaxRetryDelayMs); + } + } + } + } + + if (response.Headers.TryGetValues("X-RateLimit-Reset", out var rateLimitResetValues)) + { + var rateLimitReset = rateLimitResetValues.FirstOrDefault(); + if ( + !string.IsNullOrEmpty(rateLimitReset) + && long.TryParse(rateLimitReset, out var resetTime) + ) + { + var resetDateTime = DateTimeOffset.FromUnixTimeSeconds(resetTime); + var delay = (int)(resetDateTime - DateTimeOffset.UtcNow).TotalMilliseconds; + if (delay > 0) + { + return AddPositiveJitter(Math.Min(delay, MaxRetryDelayMs)); + } + } + } + + var exponentialDelay = Math.Min(BaseRetryDelay * (1 << retryAttempt), MaxRetryDelayMs); + return AddSymmetricJitter(exponentialDelay); + } + + private static bool IsRetryableContent(HttpRequestMessage request) + { + return request.Content switch + { + IIsRetryableContent c => c.IsRetryable, + StreamContent => false, + MultipartContent content => !content.Any(c => c is StreamContent), + _ => true, + }; + } + + internal async global::System.Threading.Tasks.Task CreateHttpRequestAsync( + global::SeedNullable.Core.BaseRequest request + ) + { + var url = BuildUrl(request); + var httpRequest = new HttpRequestMessage(request.Method, url); + httpRequest.Content = request.CreateContent(); + var mergedHeaders = new Dictionary>(); + await MergeHeadersAsync(mergedHeaders, Options.Headers).ConfigureAwait(false); + MergeAdditionalHeaders(mergedHeaders, Options.AdditionalHeaders); + await MergeHeadersAsync(mergedHeaders, request.Headers).ConfigureAwait(false); + await MergeHeadersAsync(mergedHeaders, request.Options?.Headers).ConfigureAwait(false); + + MergeAdditionalHeaders(mergedHeaders, request.Options?.AdditionalHeaders ?? []); + SetHeaders(httpRequest, mergedHeaders); + return httpRequest; + } + + private static string BuildUrl(global::SeedNullable.Core.BaseRequest request) + { + var baseUrl = request.Options?.BaseUrl ?? request.BaseUrl; + var trimmedBaseUrl = baseUrl.TrimEnd('/'); + var trimmedBasePath = request.Path.TrimStart('/'); + var url = $"{trimmedBaseUrl}/{trimmedBasePath}"; + + var queryParameters = GetQueryParameters(request); + if (!queryParameters.Any()) + return url; + + url += "?"; + url = queryParameters.Aggregate( + url, + (current, queryItem) => + { + if ( + queryItem.Value + is global::System.Collections.IEnumerable collection + and not string + ) + { + var items = collection + .Cast() + .Select(value => + $"{Uri.EscapeDataString(queryItem.Key)}={Uri.EscapeDataString(value?.ToString() ?? "")}" + ) + .ToList(); + if (items.Any()) + { + current += string.Join("&", items) + "&"; + } + } + else + { + current += + $"{Uri.EscapeDataString(queryItem.Key)}={Uri.EscapeDataString(queryItem.Value)}&"; + } + + return current; + } + ); + url = url[..^1]; + return url; + } + + private static List> GetQueryParameters( + global::SeedNullable.Core.BaseRequest request + ) + { + var result = TransformToKeyValuePairs(request.Query); + if ( + request.Options?.AdditionalQueryParameters is null + || !request.Options.AdditionalQueryParameters.Any() + ) + { + return result; + } + + var additionalKeys = request + .Options.AdditionalQueryParameters.Select(p => p.Key) + .Distinct(); + foreach (var key in additionalKeys) + { + result.RemoveAll(kv => kv.Key == key); + } + + result.AddRange(request.Options.AdditionalQueryParameters); + return result; + } + + private static List> TransformToKeyValuePairs( + Dictionary inputDict + ) + { + var result = new List>(); + foreach (var kvp in inputDict) + { + switch (kvp.Value) + { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; + case string str: + result.Add(new KeyValuePair(kvp.Key, str)); + break; + case IEnumerable strList: + { + foreach (var value in strList) + { + result.Add(new KeyValuePair(kvp.Key, value)); + } + + break; + } + } + } + + return result; + } + + private static async SystemTask MergeHeadersAsync( + Dictionary> mergedHeaders, + Headers? headers + ) + { + if (headers is null) + { + return; + } + + foreach (var header in headers) + { + var value = await header.Value.ResolveAsync().ConfigureAwait(false); + if (value is not null) + { + mergedHeaders[header.Key] = [value]; + } + } + } + + private static void MergeAdditionalHeaders( + Dictionary> mergedHeaders, + IEnumerable>? headers + ) + { + if (headers is null) + { + return; + } + + var usedKeys = new HashSet(); + foreach (var header in headers) + { + if (header.Value is null) + { + mergedHeaders.Remove(header.Key); + usedKeys.Remove(header.Key); + continue; + } + + if (usedKeys.Contains(header.Key)) + { + mergedHeaders[header.Key].Add(header.Value); + } + else + { + mergedHeaders[header.Key] = [header.Value]; + usedKeys.Add(header.Key); + } + } + } + + private void SetHeaders( + HttpRequestMessage httpRequest, + Dictionary> mergedHeaders + ) + { + foreach (var kv in mergedHeaders) + { + foreach (var header in kv.Value) + { + if (header is null) + { + continue; + } + + httpRequest.Headers.TryAddWithoutValidation(kv.Key, header); + } + } + } + + private static (Encoding encoding, string? charset, string mediaType) ParseContentTypeOrDefault( + string? contentType, + Encoding encodingFallback, + string mediaTypeFallback + ) + { + var encoding = encodingFallback; + var mediaType = mediaTypeFallback; + string? charset = null; + if (string.IsNullOrEmpty(contentType)) + { + return (encoding, charset, mediaType); + } + + if (!MediaTypeHeaderValue.TryParse(contentType, out var mediaTypeHeaderValue)) + { + return (encoding, charset, mediaType); + } + + if (!string.IsNullOrEmpty(mediaTypeHeaderValue.CharSet)) + { + charset = mediaTypeHeaderValue.CharSet; + encoding = Encoding.GetEncoding(mediaTypeHeaderValue.CharSet); + } + + if (!string.IsNullOrEmpty(mediaTypeHeaderValue.MediaType)) + { + mediaType = mediaTypeHeaderValue.MediaType; + } + + return (encoding, charset, mediaType); + } + + /// + [Obsolete("Use global::SeedNullable.Core.ApiResponse instead.")] + internal record ApiResponse : global::SeedNullable.Core.ApiResponse; + + /// + [Obsolete("Use global::SeedNullable.Core.BaseRequest instead.")] + internal abstract record BaseApiRequest : global::SeedNullable.Core.BaseRequest; + + /// + [Obsolete("Use global::SeedNullable.Core.EmptyRequest instead.")] + internal abstract record EmptyApiRequest : global::SeedNullable.Core.EmptyRequest; + + /// + [Obsolete("Use global::SeedNullable.Core.JsonRequest instead.")] + internal abstract record JsonApiRequest : global::SeedNullable.Core.JsonRequest; + + /// + [Obsolete("Use global::SeedNullable.Core.MultipartFormRequest instead.")] + internal abstract record MultipartFormRequest : global::SeedNullable.Core.MultipartFormRequest; + + /// + [Obsolete("Use global::SeedNullable.Core.StreamRequest instead.")] + internal abstract record StreamApiRequest : global::SeedNullable.Core.StreamRequest; +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/StreamRequest.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/StreamRequest.cs new file mode 100644 index 000000000000..4b0663081baf --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/StreamRequest.cs @@ -0,0 +1,29 @@ +using System.Net.Http; +using System.Net.Http.Headers; + +namespace SeedNullable.Core; + +/// +/// The request object to be sent for streaming uploads. +/// +internal record StreamRequest : BaseRequest +{ + internal Stream? Body { get; init; } + + internal override HttpContent? CreateContent() + { + if (Body is null) + { + return null; + } + + var content = new StreamContent(Body) + { + Headers = + { + ContentType = MediaTypeHeaderValue.Parse(ContentType ?? "application/octet-stream"), + }, + }; + return content; + } +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/StringEnum.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/StringEnum.cs new file mode 100644 index 000000000000..db499f1075ce --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/StringEnum.cs @@ -0,0 +1,8 @@ +using System.Text.Json.Serialization; + +namespace SeedNullable.Core; + +public interface IStringEnum : IEquatable +{ + public string Value { get; } +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/StringEnumExtensions.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/StringEnumExtensions.cs new file mode 100644 index 000000000000..9c48f6c2eefb --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/StringEnumExtensions.cs @@ -0,0 +1,6 @@ +namespace SeedNullable.Core; + +internal static class StringEnumExtensions +{ + public static string Stringify(this IStringEnum stringEnum) => stringEnum.Value; +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/StringEnumSerializer.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/StringEnumSerializer.cs new file mode 100644 index 000000000000..dcf3dc00b00c --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/StringEnumSerializer.cs @@ -0,0 +1,25 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedNullable.Core; + +internal class StringEnumSerializer : JsonConverter + where T : IStringEnum +{ + public override T? Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var stringValue = + reader.GetString() + ?? throw new global::System.Exception("The JSON value could not be read as a string."); + return (T?)Activator.CreateInstance(typeToConvert, stringValue); + } + + public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.Value); + } +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/ValueConvert.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/ValueConvert.cs new file mode 100644 index 000000000000..199cb3d8078a --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Core/ValueConvert.cs @@ -0,0 +1,115 @@ +using global::System.Globalization; + +namespace SeedNullable.Core; + +/// +/// Convert values to string for path and query parameters. +/// +public static class ValueConvert +{ + internal static string ToPathParameterString(T value) => ToString(value); + + internal static string ToPathParameterString(bool v) => ToString(v); + + internal static string ToPathParameterString(int v) => ToString(v); + + internal static string ToPathParameterString(long v) => ToString(v); + + internal static string ToPathParameterString(float v) => ToString(v); + + internal static string ToPathParameterString(double v) => ToString(v); + + internal static string ToPathParameterString(decimal v) => ToString(v); + + internal static string ToPathParameterString(short v) => ToString(v); + + internal static string ToPathParameterString(ushort v) => ToString(v); + + internal static string ToPathParameterString(uint v) => ToString(v); + + internal static string ToPathParameterString(ulong v) => ToString(v); + + internal static string ToPathParameterString(string v) => ToString(v); + + internal static string ToPathParameterString(char v) => ToString(v); + + internal static string ToPathParameterString(Guid v) => ToString(v); + + internal static string ToQueryStringValue(T value) => value is null ? "" : ToString(value); + + internal static string ToQueryStringValue(bool v) => ToString(v); + + internal static string ToQueryStringValue(int v) => ToString(v); + + internal static string ToQueryStringValue(long v) => ToString(v); + + internal static string ToQueryStringValue(float v) => ToString(v); + + internal static string ToQueryStringValue(double v) => ToString(v); + + internal static string ToQueryStringValue(decimal v) => ToString(v); + + internal static string ToQueryStringValue(short v) => ToString(v); + + internal static string ToQueryStringValue(ushort v) => ToString(v); + + internal static string ToQueryStringValue(uint v) => ToString(v); + + internal static string ToQueryStringValue(ulong v) => ToString(v); + + internal static string ToQueryStringValue(string v) => v is null ? "" : v; + + internal static string ToQueryStringValue(char v) => ToString(v); + + internal static string ToQueryStringValue(Guid v) => ToString(v); + + internal static string ToString(T value) + { + return value switch + { + null => "null", + string str => str, + true => "true", + false => "false", + int i => ToString(i), + long l => ToString(l), + float f => ToString(f), + double d => ToString(d), + decimal dec => ToString(dec), + short s => ToString(s), + ushort u => ToString(u), + uint u => ToString(u), + ulong u => ToString(u), + char c => ToString(c), + Guid guid => ToString(guid), + Enum e => JsonUtils.Serialize(e).Trim('"'), + _ => JsonUtils.Serialize(value).Trim('"'), + }; + } + + internal static string ToString(bool v) => v ? "true" : "false"; + + internal static string ToString(int v) => v.ToString(CultureInfo.InvariantCulture); + + internal static string ToString(long v) => v.ToString(CultureInfo.InvariantCulture); + + internal static string ToString(float v) => v.ToString(CultureInfo.InvariantCulture); + + internal static string ToString(double v) => v.ToString(CultureInfo.InvariantCulture); + + internal static string ToString(decimal v) => v.ToString(CultureInfo.InvariantCulture); + + internal static string ToString(short v) => v.ToString(CultureInfo.InvariantCulture); + + internal static string ToString(ushort v) => v.ToString(CultureInfo.InvariantCulture); + + internal static string ToString(uint v) => v.ToString(CultureInfo.InvariantCulture); + + internal static string ToString(ulong v) => v.ToString(CultureInfo.InvariantCulture); + + internal static string ToString(char v) => v.ToString(CultureInfo.InvariantCulture); + + internal static string ToString(string v) => v; + + internal static string ToString(Guid v) => v.ToString("D"); +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/ISeedNullableClient.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/ISeedNullableClient.cs new file mode 100644 index 000000000000..9aa3ee6500c1 --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/ISeedNullableClient.cs @@ -0,0 +1,6 @@ +namespace SeedNullable; + +public partial interface ISeedNullableClient +{ + public NullableClient Nullable { get; } +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Nullable/INullableClient.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Nullable/INullableClient.cs new file mode 100644 index 000000000000..c7a43c6570d2 --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Nullable/INullableClient.cs @@ -0,0 +1,22 @@ +namespace SeedNullable; + +public partial interface INullableClient +{ + Task> GetUsersAsync( + GetUsersRequest request, + RequestOptions? options = null, + CancellationToken cancellationToken = default + ); + + Task CreateUserAsync( + CreateUserRequest request, + RequestOptions? options = null, + CancellationToken cancellationToken = default + ); + + Task DeleteUserAsync( + DeleteUserRequest request, + RequestOptions? options = null, + CancellationToken cancellationToken = default + ); +} diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Nullable/NullableClient.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Nullable/NullableClient.cs new file mode 100644 index 000000000000..2d67dc18c229 --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Nullable/NullableClient.cs @@ -0,0 +1,188 @@ +using System.Text.Json; +using SeedNullable.Core; + +namespace SeedNullable; + +public partial class NullableClient : INullableClient +{ + private RawClient _client; + + internal NullableClient(RawClient client) + { + _client = client; + } + + /// + /// await client.Nullable.GetUsersAsync( + /// new GetUsersRequest + /// { + /// Usernames = ["usernames"], + /// Avatar = "avatar", + /// Activated = [true], + /// Tags = ["tags"], + /// Extra = true, + /// } + /// ); + /// + public async Task> GetUsersAsync( + GetUsersRequest request, + RequestOptions? options = null, + CancellationToken cancellationToken = default + ) + { + var _query = new Dictionary(); + _query["usernames"] = request.Usernames; + _query["activated"] = request + .Activated.Select(_value => JsonUtils.Serialize(_value)) + .ToList(); + _query["tags"] = request.Tags; + if (request.Avatar != null) + { + _query["avatar"] = request.Avatar; + } + if (request.Extra != null) + { + _query["extra"] = JsonUtils.Serialize(request.Extra.Value); + } + var response = await _client + .SendRequestAsync( + new JsonRequest + { + BaseUrl = _client.Options.BaseUrl, + Method = HttpMethod.Get, + Path = "/users", + Query = _query, + Options = options, + }, + cancellationToken + ) + .ConfigureAwait(false); + if (response.StatusCode is >= 200 and < 400) + { + var responseBody = await response.Raw.Content.ReadAsStringAsync(); + try + { + return JsonUtils.Deserialize>(responseBody)!; + } + catch (JsonException e) + { + throw new SeedNullableException("Failed to deserialize response", e); + } + } + + { + var responseBody = await response.Raw.Content.ReadAsStringAsync(); + throw new SeedNullableApiException( + $"Error with status code {response.StatusCode}", + response.StatusCode, + responseBody + ); + } + } + + /// + /// await client.Nullable.CreateUserAsync( + /// new CreateUserRequest + /// { + /// Username = "username", + /// Tags = new List<string>() { "tags", "tags" }, + /// Metadata = new Metadata + /// { + /// CreatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000), + /// UpdatedAt = new DateTime(2024, 01, 15, 09, 30, 00, 000), + /// Avatar = "avatar", + /// Activated = true, + /// Status = new Status(new Status.Active()), + /// Values = new Dictionary<string, string>() { { "values", "values" } }, + /// }, + /// Avatar = "avatar", + /// } + /// ); + /// + public async Task CreateUserAsync( + CreateUserRequest request, + RequestOptions? options = null, + CancellationToken cancellationToken = default + ) + { + var response = await _client + .SendRequestAsync( + new JsonRequest + { + BaseUrl = _client.Options.BaseUrl, + Method = HttpMethod.Post, + Path = "/users", + Body = request, + Options = options, + }, + cancellationToken + ) + .ConfigureAwait(false); + if (response.StatusCode is >= 200 and < 400) + { + var responseBody = await response.Raw.Content.ReadAsStringAsync(); + try + { + return JsonUtils.Deserialize(responseBody)!; + } + catch (JsonException e) + { + throw new SeedNullableException("Failed to deserialize response", e); + } + } + + { + var responseBody = await response.Raw.Content.ReadAsStringAsync(); + throw new SeedNullableApiException( + $"Error with status code {response.StatusCode}", + response.StatusCode, + responseBody + ); + } + } + + /// + /// await client.Nullable.DeleteUserAsync(new DeleteUserRequest { Username = "xy" }); + /// + public async Task DeleteUserAsync( + DeleteUserRequest request, + RequestOptions? options = null, + CancellationToken cancellationToken = default + ) + { + var response = await _client + .SendRequestAsync( + new JsonRequest + { + BaseUrl = _client.Options.BaseUrl, + Method = HttpMethod.Delete, + Path = "/users", + Body = request, + Options = options, + }, + cancellationToken + ) + .ConfigureAwait(false); + if (response.StatusCode is >= 200 and < 400) + { + var responseBody = await response.Raw.Content.ReadAsStringAsync(); + try + { + return JsonUtils.Deserialize(responseBody)!; + } + catch (JsonException e) + { + throw new SeedNullableException("Failed to deserialize response", e); + } + } + + { + var responseBody = await response.Raw.Content.ReadAsStringAsync(); + throw new SeedNullableApiException( + $"Error with status code {response.StatusCode}", + response.StatusCode, + responseBody + ); + } + } +} diff --git a/seed/csharp-sdk/nullable/src/SeedNullable/Nullable/Requests/CreateUserRequest.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Nullable/Requests/CreateUserRequest.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable/Nullable/Requests/CreateUserRequest.cs rename to seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Nullable/Requests/CreateUserRequest.cs diff --git a/seed/csharp-sdk/nullable/src/SeedNullable/Nullable/Requests/DeleteUserRequest.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Nullable/Requests/DeleteUserRequest.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable/Nullable/Requests/DeleteUserRequest.cs rename to seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Nullable/Requests/DeleteUserRequest.cs diff --git a/seed/csharp-sdk/nullable/src/SeedNullable/Nullable/Requests/GetUsersRequest.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Nullable/Requests/GetUsersRequest.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable/Nullable/Requests/GetUsersRequest.cs rename to seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Nullable/Requests/GetUsersRequest.cs diff --git a/seed/csharp-sdk/nullable/src/SeedNullable/Nullable/Types/Metadata.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Nullable/Types/Metadata.cs similarity index 94% rename from seed/csharp-sdk/nullable/src/SeedNullable/Nullable/Types/Metadata.cs rename to seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Nullable/Types/Metadata.cs index 73c98db0927b..593714c18e21 100644 --- a/seed/csharp-sdk/nullable/src/SeedNullable/Nullable/Types/Metadata.cs +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Nullable/Types/Metadata.cs @@ -27,7 +27,7 @@ public record Metadata : IJsonOnDeserialized public required Status Status { get; set; } [JsonPropertyName("values")] - public Dictionary? Values { get; set; } + public Dictionary? Values { get; set; } [JsonIgnore] public ReadOnlyAdditionalProperties AdditionalProperties { get; private set; } = new(); diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Nullable/Types/Status.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Nullable/Types/Status.cs new file mode 100644 index 000000000000..01289bfe6375 --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Nullable/Types/Status.cs @@ -0,0 +1,301 @@ +// ReSharper disable NullableWarningSuppressionIsUsed +// ReSharper disable InconsistentNaming + +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using SeedNullable.Core; + +namespace SeedNullable; + +[JsonConverter(typeof(Status.JsonConverter))] +[Serializable] +public record Status +{ + internal Status(string type, object? value) + { + Type = type; + Value = value; + } + + /// + /// Create an instance of Status with . + /// + public Status(Status.Active value) + { + Type = "active"; + Value = value.Value; + } + + /// + /// Create an instance of Status with . + /// + public Status(Status.Archived value) + { + Type = "archived"; + Value = value.Value; + } + + /// + /// Create an instance of Status with . + /// + public Status(Status.SoftDeleted value) + { + Type = "soft-deleted"; + Value = value.Value; + } + + /// + /// Discriminant value + /// + [JsonPropertyName("type")] + public string Type { get; internal set; } + + /// + /// Discriminated union value + /// + public object? Value { get; internal set; } + + /// + /// Returns true if is "active" + /// + public bool IsActive => Type == "active"; + + /// + /// Returns true if is "archived" + /// + public bool IsArchived => Type == "archived"; + + /// + /// Returns true if is "soft-deleted" + /// + public bool IsSoftDeleted => Type == "soft-deleted"; + + /// + /// Returns the value as a if is 'active', otherwise throws an exception. + /// + /// Thrown when is not 'active'. + public object AsActive() => + IsActive ? Value! : throw new System.Exception("Status.Type is not 'active'"); + + /// + /// Returns the value as a if is 'archived', otherwise throws an exception. + /// + /// Thrown when is not 'archived'. + public DateTime? AsArchived() => + IsArchived + ? (DateTime?)Value! + : throw new System.Exception("Status.Type is not 'archived'"); + + /// + /// Returns the value as a if is 'soft-deleted', otherwise throws an exception. + /// + /// Thrown when is not 'soft-deleted'. + public DateTime? AsSoftDeleted() => + IsSoftDeleted + ? (DateTime?)Value! + : throw new System.Exception("Status.Type is not 'soft-deleted'"); + + public T Match( + Func onActive, + Func onArchived, + Func onSoftDeleted, + Func onUnknown_ + ) + { + return Type switch + { + "active" => onActive(AsActive()), + "archived" => onArchived(AsArchived()), + "soft-deleted" => onSoftDeleted(AsSoftDeleted()), + _ => onUnknown_(Type, Value), + }; + } + + public void Visit( + Action onActive, + Action onArchived, + Action onSoftDeleted, + Action onUnknown_ + ) + { + switch (Type) + { + case "active": + onActive(AsActive()); + break; + case "archived": + onArchived(AsArchived()); + break; + case "soft-deleted": + onSoftDeleted(AsSoftDeleted()); + break; + default: + onUnknown_(Type, Value); + break; + } + } + + /// + /// Attempts to cast the value to a and returns true if successful. + /// + public bool TryAsActive(out object? value) + { + if (Type == "active") + { + value = Value!; + return true; + } + value = null; + return false; + } + + /// + /// Attempts to cast the value to a and returns true if successful. + /// + public bool TryAsArchived(out DateTime? value) + { + if (Type == "archived") + { + value = (DateTime?)Value!; + return true; + } + value = null; + return false; + } + + /// + /// Attempts to cast the value to a and returns true if successful. + /// + public bool TryAsSoftDeleted(out DateTime? value) + { + if (Type == "soft-deleted") + { + value = (DateTime?)Value!; + return true; + } + value = null; + return false; + } + + public override string ToString() => JsonUtils.Serialize(this); + + public static implicit operator Status(Status.Archived value) => new(value); + + public static implicit operator Status(Status.SoftDeleted value) => new(value); + + [Serializable] + internal sealed class JsonConverter : JsonConverter + { + public override bool CanConvert(System.Type typeToConvert) => + typeof(Status).IsAssignableFrom(typeToConvert); + + public override Status Read( + ref Utf8JsonReader reader, + System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var json = JsonElement.ParseValue(ref reader); + if (!json.TryGetProperty("type", out var discriminatorElement)) + { + throw new JsonException("Missing discriminator property 'type'"); + } + if (discriminatorElement.ValueKind != JsonValueKind.String) + { + if (discriminatorElement.ValueKind == JsonValueKind.Null) + { + throw new JsonException("Discriminator property 'type' is null"); + } + + throw new JsonException( + $"Discriminator property 'type' is not a string, instead is {discriminatorElement.ToString()}" + ); + } + + var discriminator = + discriminatorElement.GetString() + ?? throw new JsonException("Discriminator property 'type' is null"); + + var value = discriminator switch + { + "active" => new { }, + "archived" => json.GetProperty("value").Deserialize(options), + "soft-deleted" => json.GetProperty("value").Deserialize(options), + _ => json.Deserialize(options), + }; + return new Status(discriminator, value); + } + + public override void Write( + Utf8JsonWriter writer, + Status value, + JsonSerializerOptions options + ) + { + JsonNode json = + value.Type switch + { + "active" => null, + "archived" => new JsonObject + { + ["value"] = JsonSerializer.SerializeToNode(value.Value, options), + }, + "soft-deleted" => new JsonObject + { + ["value"] = JsonSerializer.SerializeToNode(value.Value, options), + }, + _ => JsonSerializer.SerializeToNode(value.Value, options), + } ?? new JsonObject(); + json["type"] = value.Type; + json.WriteTo(writer, options); + } + } + + /// + /// Discriminated union type for active + /// + [Serializable] + public record Active + { + internal object Value => new { }; + + public override string ToString() => Value.ToString() ?? "null"; + } + + /// + /// Discriminated union type for archived + /// + [Serializable] + public record Archived + { + public Archived(DateTime? value) + { + Value = value; + } + + internal DateTime? Value { get; set; } + + public override string ToString() => Value?.ToString() ?? "null"; + + public static implicit operator Status.Archived(DateTime? value) => new(value); + } + + /// + /// Discriminated union type for soft-deleted + /// + [Serializable] + public record SoftDeleted + { + public SoftDeleted(DateTime? value) + { + Value = value; + } + + internal DateTime? Value { get; set; } + + public override string ToString() => Value?.ToString() ?? "null"; + + public static implicit operator Status.SoftDeleted(DateTime? value) => new(value); + } +} diff --git a/seed/csharp-sdk/nullable/src/SeedNullable/Nullable/Types/User.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Nullable/Types/User.cs similarity index 100% rename from seed/csharp-sdk/nullable/src/SeedNullable/Nullable/Types/User.cs rename to seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/Nullable/Types/User.cs diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/SeedNullable.Custom.props b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/SeedNullable.Custom.props new file mode 100644 index 000000000000..17a84cada530 --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/SeedNullable.Custom.props @@ -0,0 +1,20 @@ + + + + diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/SeedNullable.csproj b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/SeedNullable.csproj new file mode 100644 index 000000000000..0718f475c8c6 --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/SeedNullable.csproj @@ -0,0 +1,58 @@ + + + net462;net8.0;net7.0;net6.0;netstandard2.0 + enable + 12 + enable + 0.0.1 + $(Version) + $(Version) + README.md + https://github.com/nullable/fern + true + + + + false + + + $(DefineConstants);USE_PORTABLE_DATE_ONLY + true + + + + + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + <_Parameter1>SeedNullable.Test + + + + + diff --git a/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/SeedNullableClient.cs b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/SeedNullableClient.cs new file mode 100644 index 000000000000..d33424f8e0fa --- /dev/null +++ b/seed/csharp-sdk/nullable/no-custom-config/src/SeedNullable/SeedNullableClient.cs @@ -0,0 +1,33 @@ +using SeedNullable.Core; + +namespace SeedNullable; + +public partial class SeedNullableClient : ISeedNullableClient +{ + private readonly RawClient _client; + + public SeedNullableClient(ClientOptions? clientOptions = null) + { + var defaultHeaders = new Headers( + new Dictionary() + { + { "X-Fern-Language", "C#" }, + { "X-Fern-SDK-Name", "SeedNullable" }, + { "X-Fern-SDK-Version", Version.Current }, + { "User-Agent", "Fernnullable/0.0.1" }, + } + ); + clientOptions ??= new ClientOptions(); + foreach (var header in defaultHeaders) + { + if (!clientOptions.Headers.ContainsKey(header.Key)) + { + clientOptions.Headers[header.Key] = header.Value; + } + } + _client = new RawClient(clientOptions); + Nullable = new NullableClient(_client); + } + + public NullableClient Nullable { get; } +} diff --git a/seed/csharp-sdk/nullable/src/SeedNullable/Core/JsonConfiguration.cs b/seed/csharp-sdk/nullable/src/SeedNullable/Core/JsonConfiguration.cs deleted file mode 100644 index 30ca35b689ba..000000000000 --- a/seed/csharp-sdk/nullable/src/SeedNullable/Core/JsonConfiguration.cs +++ /dev/null @@ -1,180 +0,0 @@ -using global::System.Reflection; -using global::System.Text.Json; -using global::System.Text.Json.Nodes; -using global::System.Text.Json.Serialization; -using global::System.Text.Json.Serialization.Metadata; - -namespace SeedNullable.Core; - -internal static partial class JsonOptions -{ - internal static readonly JsonSerializerOptions JsonSerializerOptions; - - static JsonOptions() - { - var options = new JsonSerializerOptions - { - Converters = { new DateTimeSerializer(), -#if USE_PORTABLE_DATE_ONLY - new DateOnlyConverter(), -#endif - new OneOfSerializer() }, -#if DEBUG - WriteIndented = true, -#endif - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - TypeInfoResolver = new DefaultJsonTypeInfoResolver - { - Modifiers = - { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, - }, - }, - }; - ConfigureJsonSerializerOptions(options); - JsonSerializerOptions = options; - } - - static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); -} - -internal static class JsonUtils -{ - internal static string Serialize(T obj) => - JsonSerializer.Serialize(obj, JsonOptions.JsonSerializerOptions); - - internal static JsonElement SerializeToElement(T obj) => - JsonSerializer.SerializeToElement(obj, JsonOptions.JsonSerializerOptions); - - internal static JsonDocument SerializeToDocument(T obj) => - JsonSerializer.SerializeToDocument(obj, JsonOptions.JsonSerializerOptions); - - internal static JsonNode? SerializeToNode(T obj) => - JsonSerializer.SerializeToNode(obj, JsonOptions.JsonSerializerOptions); - - internal static byte[] SerializeToUtf8Bytes(T obj) => - JsonSerializer.SerializeToUtf8Bytes(obj, JsonOptions.JsonSerializerOptions); - - internal static string SerializeWithAdditionalProperties( - T obj, - object? additionalProperties = null - ) - { - if (additionalProperties == null) - { - return Serialize(obj); - } - var additionalPropertiesJsonNode = SerializeToNode(additionalProperties); - if (additionalPropertiesJsonNode is not JsonObject additionalPropertiesJsonObject) - { - throw new InvalidOperationException( - "The additional properties must serialize to a JSON object." - ); - } - var jsonNode = SerializeToNode(obj); - if (jsonNode is not JsonObject jsonObject) - { - throw new InvalidOperationException( - "The serialized object must be a JSON object to add properties." - ); - } - MergeJsonObjects(jsonObject, additionalPropertiesJsonObject); - return jsonObject.ToJsonString(JsonOptions.JsonSerializerOptions); - } - - private static void MergeJsonObjects(JsonObject baseObject, JsonObject overrideObject) - { - foreach (var property in overrideObject) - { - if (!baseObject.TryGetPropertyValue(property.Key, out JsonNode? existingValue)) - { - baseObject[property.Key] = - property.Value != null ? JsonNode.Parse(property.Value.ToJsonString()) : null; - continue; - } - if ( - existingValue is JsonObject nestedBaseObject - && property.Value is JsonObject nestedOverrideObject - ) - { - // If both values are objects, recursively merge them. - MergeJsonObjects(nestedBaseObject, nestedOverrideObject); - continue; - } - // Otherwise, the overrideObject takes precedence. - baseObject[property.Key] = - property.Value != null ? JsonNode.Parse(property.Value.ToJsonString()) : null; - } - } - - internal static T Deserialize(string json) => - JsonSerializer.Deserialize(json, JsonOptions.JsonSerializerOptions)!; -} diff --git a/seed/csharp-sdk/oauth-client-credentials-custom/src/SeedOauthClientCredentials.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/oauth-client-credentials-custom/src/SeedOauthClientCredentials.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/oauth-client-credentials-custom/src/SeedOauthClientCredentials.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/oauth-client-credentials-custom/src/SeedOauthClientCredentials.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/oauth-client-credentials-custom/src/SeedOauthClientCredentials.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/oauth-client-credentials-custom/src/SeedOauthClientCredentials.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..bd04a7ff35ab --- /dev/null +++ b/seed/csharp-sdk/oauth-client-credentials-custom/src/SeedOauthClientCredentials.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedOauthClientCredentials.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/oauth-client-credentials-custom/src/SeedOauthClientCredentials/Core/JsonConfiguration.cs b/seed/csharp-sdk/oauth-client-credentials-custom/src/SeedOauthClientCredentials/Core/JsonConfiguration.cs index 7fad0704c99f..7dc9306a0755 100644 --- a/seed/csharp-sdk/oauth-client-credentials-custom/src/SeedOauthClientCredentials/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/oauth-client-credentials-custom/src/SeedOauthClientCredentials/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/oauth-client-credentials-custom/src/SeedOauthClientCredentials/Core/NullableAttribute.cs b/seed/csharp-sdk/oauth-client-credentials-custom/src/SeedOauthClientCredentials/Core/NullableAttribute.cs new file mode 100644 index 000000000000..74546db42738 --- /dev/null +++ b/seed/csharp-sdk/oauth-client-credentials-custom/src/SeedOauthClientCredentials/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedOauthClientCredentials.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/oauth-client-credentials-custom/src/SeedOauthClientCredentials/Core/Optional.cs b/seed/csharp-sdk/oauth-client-credentials-custom/src/SeedOauthClientCredentials/Core/Optional.cs new file mode 100644 index 000000000000..a67724b82d00 --- /dev/null +++ b/seed/csharp-sdk/oauth-client-credentials-custom/src/SeedOauthClientCredentials/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedOauthClientCredentials.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/oauth-client-credentials-custom/src/SeedOauthClientCredentials/Core/OptionalAttribute.cs b/seed/csharp-sdk/oauth-client-credentials-custom/src/SeedOauthClientCredentials/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..0f4e8bcf000d --- /dev/null +++ b/seed/csharp-sdk/oauth-client-credentials-custom/src/SeedOauthClientCredentials/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedOauthClientCredentials.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/oauth-client-credentials-custom/src/SeedOauthClientCredentials/Core/RawClient.cs b/seed/csharp-sdk/oauth-client-credentials-custom/src/SeedOauthClientCredentials/Core/RawClient.cs index 0ba737589025..17a9804395b9 100644 --- a/seed/csharp-sdk/oauth-client-credentials-custom/src/SeedOauthClientCredentials/Core/RawClient.cs +++ b/seed/csharp-sdk/oauth-client-credentials-custom/src/SeedOauthClientCredentials/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/oauth-client-credentials-default/src/SeedOauthClientCredentialsDefault.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/oauth-client-credentials-default/src/SeedOauthClientCredentialsDefault.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/oauth-client-credentials-default/src/SeedOauthClientCredentialsDefault.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/oauth-client-credentials-default/src/SeedOauthClientCredentialsDefault.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/oauth-client-credentials-default/src/SeedOauthClientCredentialsDefault.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/oauth-client-credentials-default/src/SeedOauthClientCredentialsDefault.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..c7b3f525244d --- /dev/null +++ b/seed/csharp-sdk/oauth-client-credentials-default/src/SeedOauthClientCredentialsDefault.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedOauthClientCredentialsDefault.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/oauth-client-credentials-default/src/SeedOauthClientCredentialsDefault/Core/JsonConfiguration.cs b/seed/csharp-sdk/oauth-client-credentials-default/src/SeedOauthClientCredentialsDefault/Core/JsonConfiguration.cs index 5b71a0a59ef3..3ec9c720673c 100644 --- a/seed/csharp-sdk/oauth-client-credentials-default/src/SeedOauthClientCredentialsDefault/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/oauth-client-credentials-default/src/SeedOauthClientCredentialsDefault/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/oauth-client-credentials-default/src/SeedOauthClientCredentialsDefault/Core/NullableAttribute.cs b/seed/csharp-sdk/oauth-client-credentials-default/src/SeedOauthClientCredentialsDefault/Core/NullableAttribute.cs new file mode 100644 index 000000000000..de12d9d71058 --- /dev/null +++ b/seed/csharp-sdk/oauth-client-credentials-default/src/SeedOauthClientCredentialsDefault/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedOauthClientCredentialsDefault.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/oauth-client-credentials-default/src/SeedOauthClientCredentialsDefault/Core/Optional.cs b/seed/csharp-sdk/oauth-client-credentials-default/src/SeedOauthClientCredentialsDefault/Core/Optional.cs new file mode 100644 index 000000000000..1b1974e88402 --- /dev/null +++ b/seed/csharp-sdk/oauth-client-credentials-default/src/SeedOauthClientCredentialsDefault/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedOauthClientCredentialsDefault.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/oauth-client-credentials-default/src/SeedOauthClientCredentialsDefault/Core/OptionalAttribute.cs b/seed/csharp-sdk/oauth-client-credentials-default/src/SeedOauthClientCredentialsDefault/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..35eb2b29f541 --- /dev/null +++ b/seed/csharp-sdk/oauth-client-credentials-default/src/SeedOauthClientCredentialsDefault/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedOauthClientCredentialsDefault.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/oauth-client-credentials-default/src/SeedOauthClientCredentialsDefault/Core/RawClient.cs b/seed/csharp-sdk/oauth-client-credentials-default/src/SeedOauthClientCredentialsDefault/Core/RawClient.cs index 4945f80f2138..bdcb1cbb490f 100644 --- a/seed/csharp-sdk/oauth-client-credentials-default/src/SeedOauthClientCredentialsDefault/Core/RawClient.cs +++ b/seed/csharp-sdk/oauth-client-credentials-default/src/SeedOauthClientCredentialsDefault/Core/RawClient.cs @@ -357,6 +357,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/oauth-client-credentials-environment-variables/src/SeedOauthClientCredentialsEnvironmentVariables.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/oauth-client-credentials-environment-variables/src/SeedOauthClientCredentialsEnvironmentVariables.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/oauth-client-credentials-environment-variables/src/SeedOauthClientCredentialsEnvironmentVariables.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/oauth-client-credentials-environment-variables/src/SeedOauthClientCredentialsEnvironmentVariables.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/oauth-client-credentials-environment-variables/src/SeedOauthClientCredentialsEnvironmentVariables.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/oauth-client-credentials-environment-variables/src/SeedOauthClientCredentialsEnvironmentVariables.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..27b9822ff32a --- /dev/null +++ b/seed/csharp-sdk/oauth-client-credentials-environment-variables/src/SeedOauthClientCredentialsEnvironmentVariables.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedOauthClientCredentialsEnvironmentVariables.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/oauth-client-credentials-environment-variables/src/SeedOauthClientCredentialsEnvironmentVariables/Core/JsonConfiguration.cs b/seed/csharp-sdk/oauth-client-credentials-environment-variables/src/SeedOauthClientCredentialsEnvironmentVariables/Core/JsonConfiguration.cs index c07d5e15767f..b7a510d95d9f 100644 --- a/seed/csharp-sdk/oauth-client-credentials-environment-variables/src/SeedOauthClientCredentialsEnvironmentVariables/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/oauth-client-credentials-environment-variables/src/SeedOauthClientCredentialsEnvironmentVariables/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/oauth-client-credentials-environment-variables/src/SeedOauthClientCredentialsEnvironmentVariables/Core/NullableAttribute.cs b/seed/csharp-sdk/oauth-client-credentials-environment-variables/src/SeedOauthClientCredentialsEnvironmentVariables/Core/NullableAttribute.cs new file mode 100644 index 000000000000..67756284e299 --- /dev/null +++ b/seed/csharp-sdk/oauth-client-credentials-environment-variables/src/SeedOauthClientCredentialsEnvironmentVariables/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedOauthClientCredentialsEnvironmentVariables.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/oauth-client-credentials-environment-variables/src/SeedOauthClientCredentialsEnvironmentVariables/Core/Optional.cs b/seed/csharp-sdk/oauth-client-credentials-environment-variables/src/SeedOauthClientCredentialsEnvironmentVariables/Core/Optional.cs new file mode 100644 index 000000000000..a2e516e9c05e --- /dev/null +++ b/seed/csharp-sdk/oauth-client-credentials-environment-variables/src/SeedOauthClientCredentialsEnvironmentVariables/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedOauthClientCredentialsEnvironmentVariables.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/oauth-client-credentials-environment-variables/src/SeedOauthClientCredentialsEnvironmentVariables/Core/OptionalAttribute.cs b/seed/csharp-sdk/oauth-client-credentials-environment-variables/src/SeedOauthClientCredentialsEnvironmentVariables/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..bf8efac1e388 --- /dev/null +++ b/seed/csharp-sdk/oauth-client-credentials-environment-variables/src/SeedOauthClientCredentialsEnvironmentVariables/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedOauthClientCredentialsEnvironmentVariables.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/oauth-client-credentials-environment-variables/src/SeedOauthClientCredentialsEnvironmentVariables/Core/RawClient.cs b/seed/csharp-sdk/oauth-client-credentials-environment-variables/src/SeedOauthClientCredentialsEnvironmentVariables/Core/RawClient.cs index c6b1d993e105..0986ef061849 100644 --- a/seed/csharp-sdk/oauth-client-credentials-environment-variables/src/SeedOauthClientCredentialsEnvironmentVariables/Core/RawClient.cs +++ b/seed/csharp-sdk/oauth-client-credentials-environment-variables/src/SeedOauthClientCredentialsEnvironmentVariables/Core/RawClient.cs @@ -357,6 +357,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/oauth-client-credentials-mandatory-auth/src/SeedOauthClientCredentialsMandatoryAuth.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/oauth-client-credentials-mandatory-auth/src/SeedOauthClientCredentialsMandatoryAuth.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/oauth-client-credentials-mandatory-auth/src/SeedOauthClientCredentialsMandatoryAuth.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/oauth-client-credentials-mandatory-auth/src/SeedOauthClientCredentialsMandatoryAuth.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/oauth-client-credentials-mandatory-auth/src/SeedOauthClientCredentialsMandatoryAuth.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/oauth-client-credentials-mandatory-auth/src/SeedOauthClientCredentialsMandatoryAuth.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..2582cf1e8180 --- /dev/null +++ b/seed/csharp-sdk/oauth-client-credentials-mandatory-auth/src/SeedOauthClientCredentialsMandatoryAuth.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedOauthClientCredentialsMandatoryAuth.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/oauth-client-credentials-mandatory-auth/src/SeedOauthClientCredentialsMandatoryAuth/Core/JsonConfiguration.cs b/seed/csharp-sdk/oauth-client-credentials-mandatory-auth/src/SeedOauthClientCredentialsMandatoryAuth/Core/JsonConfiguration.cs index 0a7f98297ed7..cce70efba28b 100644 --- a/seed/csharp-sdk/oauth-client-credentials-mandatory-auth/src/SeedOauthClientCredentialsMandatoryAuth/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/oauth-client-credentials-mandatory-auth/src/SeedOauthClientCredentialsMandatoryAuth/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/oauth-client-credentials-mandatory-auth/src/SeedOauthClientCredentialsMandatoryAuth/Core/NullableAttribute.cs b/seed/csharp-sdk/oauth-client-credentials-mandatory-auth/src/SeedOauthClientCredentialsMandatoryAuth/Core/NullableAttribute.cs new file mode 100644 index 000000000000..e751b523ebd2 --- /dev/null +++ b/seed/csharp-sdk/oauth-client-credentials-mandatory-auth/src/SeedOauthClientCredentialsMandatoryAuth/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedOauthClientCredentialsMandatoryAuth.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/oauth-client-credentials-mandatory-auth/src/SeedOauthClientCredentialsMandatoryAuth/Core/Optional.cs b/seed/csharp-sdk/oauth-client-credentials-mandatory-auth/src/SeedOauthClientCredentialsMandatoryAuth/Core/Optional.cs new file mode 100644 index 000000000000..075fe3cf885a --- /dev/null +++ b/seed/csharp-sdk/oauth-client-credentials-mandatory-auth/src/SeedOauthClientCredentialsMandatoryAuth/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedOauthClientCredentialsMandatoryAuth.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/oauth-client-credentials-mandatory-auth/src/SeedOauthClientCredentialsMandatoryAuth/Core/OptionalAttribute.cs b/seed/csharp-sdk/oauth-client-credentials-mandatory-auth/src/SeedOauthClientCredentialsMandatoryAuth/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..1c0c326455ca --- /dev/null +++ b/seed/csharp-sdk/oauth-client-credentials-mandatory-auth/src/SeedOauthClientCredentialsMandatoryAuth/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedOauthClientCredentialsMandatoryAuth.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/oauth-client-credentials-mandatory-auth/src/SeedOauthClientCredentialsMandatoryAuth/Core/RawClient.cs b/seed/csharp-sdk/oauth-client-credentials-mandatory-auth/src/SeedOauthClientCredentialsMandatoryAuth/Core/RawClient.cs index 59368f373abc..a696bb077321 100644 --- a/seed/csharp-sdk/oauth-client-credentials-mandatory-auth/src/SeedOauthClientCredentialsMandatoryAuth/Core/RawClient.cs +++ b/seed/csharp-sdk/oauth-client-credentials-mandatory-auth/src/SeedOauthClientCredentialsMandatoryAuth/Core/RawClient.cs @@ -357,6 +357,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/oauth-client-credentials-nested-root/src/SeedOauthClientCredentials.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/oauth-client-credentials-nested-root/src/SeedOauthClientCredentials.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/oauth-client-credentials-nested-root/src/SeedOauthClientCredentials.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/oauth-client-credentials-nested-root/src/SeedOauthClientCredentials.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/oauth-client-credentials-nested-root/src/SeedOauthClientCredentials.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/oauth-client-credentials-nested-root/src/SeedOauthClientCredentials.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..bd04a7ff35ab --- /dev/null +++ b/seed/csharp-sdk/oauth-client-credentials-nested-root/src/SeedOauthClientCredentials.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedOauthClientCredentials.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/oauth-client-credentials-nested-root/src/SeedOauthClientCredentials/Core/JsonConfiguration.cs b/seed/csharp-sdk/oauth-client-credentials-nested-root/src/SeedOauthClientCredentials/Core/JsonConfiguration.cs index 7fad0704c99f..7dc9306a0755 100644 --- a/seed/csharp-sdk/oauth-client-credentials-nested-root/src/SeedOauthClientCredentials/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/oauth-client-credentials-nested-root/src/SeedOauthClientCredentials/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/oauth-client-credentials-nested-root/src/SeedOauthClientCredentials/Core/NullableAttribute.cs b/seed/csharp-sdk/oauth-client-credentials-nested-root/src/SeedOauthClientCredentials/Core/NullableAttribute.cs new file mode 100644 index 000000000000..74546db42738 --- /dev/null +++ b/seed/csharp-sdk/oauth-client-credentials-nested-root/src/SeedOauthClientCredentials/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedOauthClientCredentials.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/oauth-client-credentials-nested-root/src/SeedOauthClientCredentials/Core/Optional.cs b/seed/csharp-sdk/oauth-client-credentials-nested-root/src/SeedOauthClientCredentials/Core/Optional.cs new file mode 100644 index 000000000000..a67724b82d00 --- /dev/null +++ b/seed/csharp-sdk/oauth-client-credentials-nested-root/src/SeedOauthClientCredentials/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedOauthClientCredentials.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/oauth-client-credentials-nested-root/src/SeedOauthClientCredentials/Core/OptionalAttribute.cs b/seed/csharp-sdk/oauth-client-credentials-nested-root/src/SeedOauthClientCredentials/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..0f4e8bcf000d --- /dev/null +++ b/seed/csharp-sdk/oauth-client-credentials-nested-root/src/SeedOauthClientCredentials/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedOauthClientCredentials.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/oauth-client-credentials-nested-root/src/SeedOauthClientCredentials/Core/RawClient.cs b/seed/csharp-sdk/oauth-client-credentials-nested-root/src/SeedOauthClientCredentials/Core/RawClient.cs index 0ba737589025..17a9804395b9 100644 --- a/seed/csharp-sdk/oauth-client-credentials-nested-root/src/SeedOauthClientCredentials/Core/RawClient.cs +++ b/seed/csharp-sdk/oauth-client-credentials-nested-root/src/SeedOauthClientCredentials/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/oauth-client-credentials-reference/src/SeedOauthClientCredentialsReference.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/oauth-client-credentials-reference/src/SeedOauthClientCredentialsReference.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/oauth-client-credentials-reference/src/SeedOauthClientCredentialsReference.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/oauth-client-credentials-reference/src/SeedOauthClientCredentialsReference.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/oauth-client-credentials-reference/src/SeedOauthClientCredentialsReference.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/oauth-client-credentials-reference/src/SeedOauthClientCredentialsReference.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..996d6472ebdf --- /dev/null +++ b/seed/csharp-sdk/oauth-client-credentials-reference/src/SeedOauthClientCredentialsReference.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedOauthClientCredentialsReference.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/oauth-client-credentials-reference/src/SeedOauthClientCredentialsReference/Core/JsonConfiguration.cs b/seed/csharp-sdk/oauth-client-credentials-reference/src/SeedOauthClientCredentialsReference/Core/JsonConfiguration.cs index 10e69e10f774..7961946f2b19 100644 --- a/seed/csharp-sdk/oauth-client-credentials-reference/src/SeedOauthClientCredentialsReference/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/oauth-client-credentials-reference/src/SeedOauthClientCredentialsReference/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/oauth-client-credentials-reference/src/SeedOauthClientCredentialsReference/Core/NullableAttribute.cs b/seed/csharp-sdk/oauth-client-credentials-reference/src/SeedOauthClientCredentialsReference/Core/NullableAttribute.cs new file mode 100644 index 000000000000..9df7bd6c2a58 --- /dev/null +++ b/seed/csharp-sdk/oauth-client-credentials-reference/src/SeedOauthClientCredentialsReference/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedOauthClientCredentialsReference.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/oauth-client-credentials-reference/src/SeedOauthClientCredentialsReference/Core/Optional.cs b/seed/csharp-sdk/oauth-client-credentials-reference/src/SeedOauthClientCredentialsReference/Core/Optional.cs new file mode 100644 index 000000000000..5de6e0bb10a0 --- /dev/null +++ b/seed/csharp-sdk/oauth-client-credentials-reference/src/SeedOauthClientCredentialsReference/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedOauthClientCredentialsReference.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/oauth-client-credentials-reference/src/SeedOauthClientCredentialsReference/Core/OptionalAttribute.cs b/seed/csharp-sdk/oauth-client-credentials-reference/src/SeedOauthClientCredentialsReference/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..d482493836ee --- /dev/null +++ b/seed/csharp-sdk/oauth-client-credentials-reference/src/SeedOauthClientCredentialsReference/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedOauthClientCredentialsReference.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/oauth-client-credentials-reference/src/SeedOauthClientCredentialsReference/Core/RawClient.cs b/seed/csharp-sdk/oauth-client-credentials-reference/src/SeedOauthClientCredentialsReference/Core/RawClient.cs index 905d180ae255..d1eb453a6425 100644 --- a/seed/csharp-sdk/oauth-client-credentials-reference/src/SeedOauthClientCredentialsReference/Core/RawClient.cs +++ b/seed/csharp-sdk/oauth-client-credentials-reference/src/SeedOauthClientCredentialsReference/Core/RawClient.cs @@ -357,6 +357,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/oauth-client-credentials-with-variables/src/SeedOauthClientCredentialsWithVariables.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/oauth-client-credentials-with-variables/src/SeedOauthClientCredentialsWithVariables.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/oauth-client-credentials-with-variables/src/SeedOauthClientCredentialsWithVariables.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/oauth-client-credentials-with-variables/src/SeedOauthClientCredentialsWithVariables.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/oauth-client-credentials-with-variables/src/SeedOauthClientCredentialsWithVariables.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/oauth-client-credentials-with-variables/src/SeedOauthClientCredentialsWithVariables.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..99099db6d1bd --- /dev/null +++ b/seed/csharp-sdk/oauth-client-credentials-with-variables/src/SeedOauthClientCredentialsWithVariables.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedOauthClientCredentialsWithVariables.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/oauth-client-credentials-with-variables/src/SeedOauthClientCredentialsWithVariables/Core/JsonConfiguration.cs b/seed/csharp-sdk/oauth-client-credentials-with-variables/src/SeedOauthClientCredentialsWithVariables/Core/JsonConfiguration.cs index 13fe8d5591cd..ffd3dcdb0817 100644 --- a/seed/csharp-sdk/oauth-client-credentials-with-variables/src/SeedOauthClientCredentialsWithVariables/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/oauth-client-credentials-with-variables/src/SeedOauthClientCredentialsWithVariables/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/oauth-client-credentials-with-variables/src/SeedOauthClientCredentialsWithVariables/Core/NullableAttribute.cs b/seed/csharp-sdk/oauth-client-credentials-with-variables/src/SeedOauthClientCredentialsWithVariables/Core/NullableAttribute.cs new file mode 100644 index 000000000000..46d7d69f82ce --- /dev/null +++ b/seed/csharp-sdk/oauth-client-credentials-with-variables/src/SeedOauthClientCredentialsWithVariables/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedOauthClientCredentialsWithVariables.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/oauth-client-credentials-with-variables/src/SeedOauthClientCredentialsWithVariables/Core/Optional.cs b/seed/csharp-sdk/oauth-client-credentials-with-variables/src/SeedOauthClientCredentialsWithVariables/Core/Optional.cs new file mode 100644 index 000000000000..24ed1d5e3cda --- /dev/null +++ b/seed/csharp-sdk/oauth-client-credentials-with-variables/src/SeedOauthClientCredentialsWithVariables/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedOauthClientCredentialsWithVariables.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/oauth-client-credentials-with-variables/src/SeedOauthClientCredentialsWithVariables/Core/OptionalAttribute.cs b/seed/csharp-sdk/oauth-client-credentials-with-variables/src/SeedOauthClientCredentialsWithVariables/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..40ee0bbe72e2 --- /dev/null +++ b/seed/csharp-sdk/oauth-client-credentials-with-variables/src/SeedOauthClientCredentialsWithVariables/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedOauthClientCredentialsWithVariables.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/oauth-client-credentials-with-variables/src/SeedOauthClientCredentialsWithVariables/Core/RawClient.cs b/seed/csharp-sdk/oauth-client-credentials-with-variables/src/SeedOauthClientCredentialsWithVariables/Core/RawClient.cs index c15bb3c2d290..b58ca63f2edb 100644 --- a/seed/csharp-sdk/oauth-client-credentials-with-variables/src/SeedOauthClientCredentialsWithVariables/Core/RawClient.cs +++ b/seed/csharp-sdk/oauth-client-credentials-with-variables/src/SeedOauthClientCredentialsWithVariables/Core/RawClient.cs @@ -357,6 +357,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/oauth-client-credentials/include-exception-handler/src/SeedOauthClientCredentials.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/oauth-client-credentials/include-exception-handler/src/SeedOauthClientCredentials.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/oauth-client-credentials/include-exception-handler/src/SeedOauthClientCredentials.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/oauth-client-credentials/include-exception-handler/src/SeedOauthClientCredentials.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/oauth-client-credentials/include-exception-handler/src/SeedOauthClientCredentials.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/oauth-client-credentials/include-exception-handler/src/SeedOauthClientCredentials.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..bd04a7ff35ab --- /dev/null +++ b/seed/csharp-sdk/oauth-client-credentials/include-exception-handler/src/SeedOauthClientCredentials.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedOauthClientCredentials.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/oauth-client-credentials/include-exception-handler/src/SeedOauthClientCredentials/Core/JsonConfiguration.cs b/seed/csharp-sdk/oauth-client-credentials/include-exception-handler/src/SeedOauthClientCredentials/Core/JsonConfiguration.cs index 7fad0704c99f..7dc9306a0755 100644 --- a/seed/csharp-sdk/oauth-client-credentials/include-exception-handler/src/SeedOauthClientCredentials/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/oauth-client-credentials/include-exception-handler/src/SeedOauthClientCredentials/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/oauth-client-credentials/include-exception-handler/src/SeedOauthClientCredentials/Core/NullableAttribute.cs b/seed/csharp-sdk/oauth-client-credentials/include-exception-handler/src/SeedOauthClientCredentials/Core/NullableAttribute.cs new file mode 100644 index 000000000000..74546db42738 --- /dev/null +++ b/seed/csharp-sdk/oauth-client-credentials/include-exception-handler/src/SeedOauthClientCredentials/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedOauthClientCredentials.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/oauth-client-credentials/include-exception-handler/src/SeedOauthClientCredentials/Core/Optional.cs b/seed/csharp-sdk/oauth-client-credentials/include-exception-handler/src/SeedOauthClientCredentials/Core/Optional.cs new file mode 100644 index 000000000000..a67724b82d00 --- /dev/null +++ b/seed/csharp-sdk/oauth-client-credentials/include-exception-handler/src/SeedOauthClientCredentials/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedOauthClientCredentials.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/oauth-client-credentials/include-exception-handler/src/SeedOauthClientCredentials/Core/OptionalAttribute.cs b/seed/csharp-sdk/oauth-client-credentials/include-exception-handler/src/SeedOauthClientCredentials/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..0f4e8bcf000d --- /dev/null +++ b/seed/csharp-sdk/oauth-client-credentials/include-exception-handler/src/SeedOauthClientCredentials/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedOauthClientCredentials.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/oauth-client-credentials/include-exception-handler/src/SeedOauthClientCredentials/Core/RawClient.cs b/seed/csharp-sdk/oauth-client-credentials/include-exception-handler/src/SeedOauthClientCredentials/Core/RawClient.cs index 0ba737589025..17a9804395b9 100644 --- a/seed/csharp-sdk/oauth-client-credentials/include-exception-handler/src/SeedOauthClientCredentials/Core/RawClient.cs +++ b/seed/csharp-sdk/oauth-client-credentials/include-exception-handler/src/SeedOauthClientCredentials/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/oauth-client-credentials/no-custom-config/src/SeedOauthClientCredentials.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/oauth-client-credentials/no-custom-config/src/SeedOauthClientCredentials.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/oauth-client-credentials/no-custom-config/src/SeedOauthClientCredentials.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/oauth-client-credentials/no-custom-config/src/SeedOauthClientCredentials.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/oauth-client-credentials/no-custom-config/src/SeedOauthClientCredentials.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/oauth-client-credentials/no-custom-config/src/SeedOauthClientCredentials.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..bd04a7ff35ab --- /dev/null +++ b/seed/csharp-sdk/oauth-client-credentials/no-custom-config/src/SeedOauthClientCredentials.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedOauthClientCredentials.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/oauth-client-credentials/no-custom-config/src/SeedOauthClientCredentials/Core/JsonConfiguration.cs b/seed/csharp-sdk/oauth-client-credentials/no-custom-config/src/SeedOauthClientCredentials/Core/JsonConfiguration.cs index 7fad0704c99f..7dc9306a0755 100644 --- a/seed/csharp-sdk/oauth-client-credentials/no-custom-config/src/SeedOauthClientCredentials/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/oauth-client-credentials/no-custom-config/src/SeedOauthClientCredentials/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/oauth-client-credentials/no-custom-config/src/SeedOauthClientCredentials/Core/NullableAttribute.cs b/seed/csharp-sdk/oauth-client-credentials/no-custom-config/src/SeedOauthClientCredentials/Core/NullableAttribute.cs new file mode 100644 index 000000000000..74546db42738 --- /dev/null +++ b/seed/csharp-sdk/oauth-client-credentials/no-custom-config/src/SeedOauthClientCredentials/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedOauthClientCredentials.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/oauth-client-credentials/no-custom-config/src/SeedOauthClientCredentials/Core/Optional.cs b/seed/csharp-sdk/oauth-client-credentials/no-custom-config/src/SeedOauthClientCredentials/Core/Optional.cs new file mode 100644 index 000000000000..a67724b82d00 --- /dev/null +++ b/seed/csharp-sdk/oauth-client-credentials/no-custom-config/src/SeedOauthClientCredentials/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedOauthClientCredentials.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/oauth-client-credentials/no-custom-config/src/SeedOauthClientCredentials/Core/OptionalAttribute.cs b/seed/csharp-sdk/oauth-client-credentials/no-custom-config/src/SeedOauthClientCredentials/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..0f4e8bcf000d --- /dev/null +++ b/seed/csharp-sdk/oauth-client-credentials/no-custom-config/src/SeedOauthClientCredentials/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedOauthClientCredentials.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/oauth-client-credentials/no-custom-config/src/SeedOauthClientCredentials/Core/RawClient.cs b/seed/csharp-sdk/oauth-client-credentials/no-custom-config/src/SeedOauthClientCredentials/Core/RawClient.cs index 0ba737589025..17a9804395b9 100644 --- a/seed/csharp-sdk/oauth-client-credentials/no-custom-config/src/SeedOauthClientCredentials/Core/RawClient.cs +++ b/seed/csharp-sdk/oauth-client-credentials/no-custom-config/src/SeedOauthClientCredentials/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/object/src/SeedObject.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/object/src/SeedObject.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/object/src/SeedObject.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/object/src/SeedObject.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/object/src/SeedObject.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/object/src/SeedObject.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..7b75fef048c7 --- /dev/null +++ b/seed/csharp-sdk/object/src/SeedObject.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedObject.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/object/src/SeedObject/Core/JsonConfiguration.cs b/seed/csharp-sdk/object/src/SeedObject/Core/JsonConfiguration.cs index a9229ab49301..a15a86f8d6b7 100644 --- a/seed/csharp-sdk/object/src/SeedObject/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/object/src/SeedObject/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/object/src/SeedObject/Core/NullableAttribute.cs b/seed/csharp-sdk/object/src/SeedObject/Core/NullableAttribute.cs new file mode 100644 index 000000000000..22cf1caedfa1 --- /dev/null +++ b/seed/csharp-sdk/object/src/SeedObject/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedObject.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/object/src/SeedObject/Core/Optional.cs b/seed/csharp-sdk/object/src/SeedObject/Core/Optional.cs new file mode 100644 index 000000000000..1e2614ed5dd7 --- /dev/null +++ b/seed/csharp-sdk/object/src/SeedObject/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedObject.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/object/src/SeedObject/Core/OptionalAttribute.cs b/seed/csharp-sdk/object/src/SeedObject/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..4886bc371189 --- /dev/null +++ b/seed/csharp-sdk/object/src/SeedObject/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedObject.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/object/src/SeedObject/Core/RawClient.cs b/seed/csharp-sdk/object/src/SeedObject/Core/RawClient.cs index 0d806eeac876..98fd086fb13b 100644 --- a/seed/csharp-sdk/object/src/SeedObject/Core/RawClient.cs +++ b/seed/csharp-sdk/object/src/SeedObject/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/objects-with-imports/src/SeedObjectsWithImports.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/objects-with-imports/src/SeedObjectsWithImports.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/objects-with-imports/src/SeedObjectsWithImports.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/objects-with-imports/src/SeedObjectsWithImports.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/objects-with-imports/src/SeedObjectsWithImports.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/objects-with-imports/src/SeedObjectsWithImports.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..bcb06303eb7b --- /dev/null +++ b/seed/csharp-sdk/objects-with-imports/src/SeedObjectsWithImports.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedObjectsWithImports.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/objects-with-imports/src/SeedObjectsWithImports/Core/JsonConfiguration.cs b/seed/csharp-sdk/objects-with-imports/src/SeedObjectsWithImports/Core/JsonConfiguration.cs index fe7de0f5e7b3..a6a1038953f6 100644 --- a/seed/csharp-sdk/objects-with-imports/src/SeedObjectsWithImports/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/objects-with-imports/src/SeedObjectsWithImports/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/objects-with-imports/src/SeedObjectsWithImports/Core/NullableAttribute.cs b/seed/csharp-sdk/objects-with-imports/src/SeedObjectsWithImports/Core/NullableAttribute.cs new file mode 100644 index 000000000000..5bb173bced93 --- /dev/null +++ b/seed/csharp-sdk/objects-with-imports/src/SeedObjectsWithImports/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedObjectsWithImports.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/objects-with-imports/src/SeedObjectsWithImports/Core/Optional.cs b/seed/csharp-sdk/objects-with-imports/src/SeedObjectsWithImports/Core/Optional.cs new file mode 100644 index 000000000000..d4158c587d9e --- /dev/null +++ b/seed/csharp-sdk/objects-with-imports/src/SeedObjectsWithImports/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedObjectsWithImports.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/objects-with-imports/src/SeedObjectsWithImports/Core/OptionalAttribute.cs b/seed/csharp-sdk/objects-with-imports/src/SeedObjectsWithImports/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..cbee186fe65f --- /dev/null +++ b/seed/csharp-sdk/objects-with-imports/src/SeedObjectsWithImports/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedObjectsWithImports.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/objects-with-imports/src/SeedObjectsWithImports/Core/RawClient.cs b/seed/csharp-sdk/objects-with-imports/src/SeedObjectsWithImports/Core/RawClient.cs index 14dce2290827..2558bd6ccc13 100644 --- a/seed/csharp-sdk/objects-with-imports/src/SeedObjectsWithImports/Core/RawClient.cs +++ b/seed/csharp-sdk/objects-with-imports/src/SeedObjectsWithImports/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/optional/no-custom-config/src/SeedObjectsWithImports.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/optional/no-custom-config/src/SeedObjectsWithImports.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/optional/no-custom-config/src/SeedObjectsWithImports.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/optional/no-custom-config/src/SeedObjectsWithImports.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/optional/no-custom-config/src/SeedObjectsWithImports.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/optional/no-custom-config/src/SeedObjectsWithImports.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..bcb06303eb7b --- /dev/null +++ b/seed/csharp-sdk/optional/no-custom-config/src/SeedObjectsWithImports.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedObjectsWithImports.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/optional/no-custom-config/src/SeedObjectsWithImports/Core/JsonConfiguration.cs b/seed/csharp-sdk/optional/no-custom-config/src/SeedObjectsWithImports/Core/JsonConfiguration.cs index fe7de0f5e7b3..a6a1038953f6 100644 --- a/seed/csharp-sdk/optional/no-custom-config/src/SeedObjectsWithImports/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/optional/no-custom-config/src/SeedObjectsWithImports/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/optional/no-custom-config/src/SeedObjectsWithImports/Core/NullableAttribute.cs b/seed/csharp-sdk/optional/no-custom-config/src/SeedObjectsWithImports/Core/NullableAttribute.cs new file mode 100644 index 000000000000..5bb173bced93 --- /dev/null +++ b/seed/csharp-sdk/optional/no-custom-config/src/SeedObjectsWithImports/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedObjectsWithImports.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/optional/no-custom-config/src/SeedObjectsWithImports/Core/Optional.cs b/seed/csharp-sdk/optional/no-custom-config/src/SeedObjectsWithImports/Core/Optional.cs new file mode 100644 index 000000000000..d4158c587d9e --- /dev/null +++ b/seed/csharp-sdk/optional/no-custom-config/src/SeedObjectsWithImports/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedObjectsWithImports.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/optional/no-custom-config/src/SeedObjectsWithImports/Core/OptionalAttribute.cs b/seed/csharp-sdk/optional/no-custom-config/src/SeedObjectsWithImports/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..cbee186fe65f --- /dev/null +++ b/seed/csharp-sdk/optional/no-custom-config/src/SeedObjectsWithImports/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedObjectsWithImports.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/optional/no-custom-config/src/SeedObjectsWithImports/Core/RawClient.cs b/seed/csharp-sdk/optional/no-custom-config/src/SeedObjectsWithImports/Core/RawClient.cs index 14dce2290827..2558bd6ccc13 100644 --- a/seed/csharp-sdk/optional/no-custom-config/src/SeedObjectsWithImports/Core/RawClient.cs +++ b/seed/csharp-sdk/optional/no-custom-config/src/SeedObjectsWithImports/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/optional/simplify-object-dictionaries/src/SeedObjectsWithImports.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/optional/simplify-object-dictionaries/src/SeedObjectsWithImports.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/optional/simplify-object-dictionaries/src/SeedObjectsWithImports.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/optional/simplify-object-dictionaries/src/SeedObjectsWithImports.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/optional/simplify-object-dictionaries/src/SeedObjectsWithImports.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/optional/simplify-object-dictionaries/src/SeedObjectsWithImports.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..bcb06303eb7b --- /dev/null +++ b/seed/csharp-sdk/optional/simplify-object-dictionaries/src/SeedObjectsWithImports.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedObjectsWithImports.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/optional/simplify-object-dictionaries/src/SeedObjectsWithImports/Core/JsonConfiguration.cs b/seed/csharp-sdk/optional/simplify-object-dictionaries/src/SeedObjectsWithImports/Core/JsonConfiguration.cs index fe7de0f5e7b3..a6a1038953f6 100644 --- a/seed/csharp-sdk/optional/simplify-object-dictionaries/src/SeedObjectsWithImports/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/optional/simplify-object-dictionaries/src/SeedObjectsWithImports/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/optional/simplify-object-dictionaries/src/SeedObjectsWithImports/Core/NullableAttribute.cs b/seed/csharp-sdk/optional/simplify-object-dictionaries/src/SeedObjectsWithImports/Core/NullableAttribute.cs new file mode 100644 index 000000000000..5bb173bced93 --- /dev/null +++ b/seed/csharp-sdk/optional/simplify-object-dictionaries/src/SeedObjectsWithImports/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedObjectsWithImports.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/optional/simplify-object-dictionaries/src/SeedObjectsWithImports/Core/Optional.cs b/seed/csharp-sdk/optional/simplify-object-dictionaries/src/SeedObjectsWithImports/Core/Optional.cs new file mode 100644 index 000000000000..d4158c587d9e --- /dev/null +++ b/seed/csharp-sdk/optional/simplify-object-dictionaries/src/SeedObjectsWithImports/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedObjectsWithImports.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/optional/simplify-object-dictionaries/src/SeedObjectsWithImports/Core/OptionalAttribute.cs b/seed/csharp-sdk/optional/simplify-object-dictionaries/src/SeedObjectsWithImports/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..cbee186fe65f --- /dev/null +++ b/seed/csharp-sdk/optional/simplify-object-dictionaries/src/SeedObjectsWithImports/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedObjectsWithImports.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/optional/simplify-object-dictionaries/src/SeedObjectsWithImports/Core/RawClient.cs b/seed/csharp-sdk/optional/simplify-object-dictionaries/src/SeedObjectsWithImports/Core/RawClient.cs index 14dce2290827..2558bd6ccc13 100644 --- a/seed/csharp-sdk/optional/simplify-object-dictionaries/src/SeedObjectsWithImports/Core/RawClient.cs +++ b/seed/csharp-sdk/optional/simplify-object-dictionaries/src/SeedObjectsWithImports/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/package-yml/src/SeedPackageYml.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/package-yml/src/SeedPackageYml.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/package-yml/src/SeedPackageYml.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/package-yml/src/SeedPackageYml.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/package-yml/src/SeedPackageYml.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/package-yml/src/SeedPackageYml.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..6e337d52e4bc --- /dev/null +++ b/seed/csharp-sdk/package-yml/src/SeedPackageYml.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedPackageYml.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/package-yml/src/SeedPackageYml/Core/JsonConfiguration.cs b/seed/csharp-sdk/package-yml/src/SeedPackageYml/Core/JsonConfiguration.cs index b2750cc6e203..63ab94697baf 100644 --- a/seed/csharp-sdk/package-yml/src/SeedPackageYml/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/package-yml/src/SeedPackageYml/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/package-yml/src/SeedPackageYml/Core/NullableAttribute.cs b/seed/csharp-sdk/package-yml/src/SeedPackageYml/Core/NullableAttribute.cs new file mode 100644 index 000000000000..cc6a31bbddb7 --- /dev/null +++ b/seed/csharp-sdk/package-yml/src/SeedPackageYml/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedPackageYml.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/package-yml/src/SeedPackageYml/Core/Optional.cs b/seed/csharp-sdk/package-yml/src/SeedPackageYml/Core/Optional.cs new file mode 100644 index 000000000000..e47cc9b5c5cc --- /dev/null +++ b/seed/csharp-sdk/package-yml/src/SeedPackageYml/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedPackageYml.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/package-yml/src/SeedPackageYml/Core/OptionalAttribute.cs b/seed/csharp-sdk/package-yml/src/SeedPackageYml/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..ce3fe5d55e87 --- /dev/null +++ b/seed/csharp-sdk/package-yml/src/SeedPackageYml/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedPackageYml.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/package-yml/src/SeedPackageYml/Core/RawClient.cs b/seed/csharp-sdk/package-yml/src/SeedPackageYml/Core/RawClient.cs index 5cd7ea91bef3..d0470f4760e3 100644 --- a/seed/csharp-sdk/package-yml/src/SeedPackageYml/Core/RawClient.cs +++ b/seed/csharp-sdk/package-yml/src/SeedPackageYml/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/pagination-custom/src/SeedPagination.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/pagination-custom/src/SeedPagination.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/pagination-custom/src/SeedPagination.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/pagination-custom/src/SeedPagination.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/pagination-custom/src/SeedPagination.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/pagination-custom/src/SeedPagination.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..a7ce652c2dbb --- /dev/null +++ b/seed/csharp-sdk/pagination-custom/src/SeedPagination.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedPagination.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/pagination-custom/src/SeedPagination/Core/JsonConfiguration.cs b/seed/csharp-sdk/pagination-custom/src/SeedPagination/Core/JsonConfiguration.cs index e94f84af24ed..a52fab0b810a 100644 --- a/seed/csharp-sdk/pagination-custom/src/SeedPagination/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/pagination-custom/src/SeedPagination/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/pagination-custom/src/SeedPagination/Core/NullableAttribute.cs b/seed/csharp-sdk/pagination-custom/src/SeedPagination/Core/NullableAttribute.cs new file mode 100644 index 000000000000..16772890c48e --- /dev/null +++ b/seed/csharp-sdk/pagination-custom/src/SeedPagination/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedPagination.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/pagination-custom/src/SeedPagination/Core/Optional.cs b/seed/csharp-sdk/pagination-custom/src/SeedPagination/Core/Optional.cs new file mode 100644 index 000000000000..a72436b1ae2e --- /dev/null +++ b/seed/csharp-sdk/pagination-custom/src/SeedPagination/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedPagination.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/pagination-custom/src/SeedPagination/Core/OptionalAttribute.cs b/seed/csharp-sdk/pagination-custom/src/SeedPagination/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..483910b676e6 --- /dev/null +++ b/seed/csharp-sdk/pagination-custom/src/SeedPagination/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedPagination.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/pagination-custom/src/SeedPagination/Core/RawClient.cs b/seed/csharp-sdk/pagination-custom/src/SeedPagination/Core/RawClient.cs index d4a10e55c304..ce1c3cf089ea 100644 --- a/seed/csharp-sdk/pagination-custom/src/SeedPagination/Core/RawClient.cs +++ b/seed/csharp-sdk/pagination-custom/src/SeedPagination/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/pagination/custom-pager-with-exception-handler/src/SeedPagination.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/pagination/custom-pager-with-exception-handler/src/SeedPagination.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/pagination/custom-pager-with-exception-handler/src/SeedPagination.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/pagination/custom-pager-with-exception-handler/src/SeedPagination.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/pagination/custom-pager-with-exception-handler/src/SeedPagination.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/pagination/custom-pager-with-exception-handler/src/SeedPagination.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..a7ce652c2dbb --- /dev/null +++ b/seed/csharp-sdk/pagination/custom-pager-with-exception-handler/src/SeedPagination.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedPagination.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/pagination/custom-pager-with-exception-handler/src/SeedPagination/Core/JsonConfiguration.cs b/seed/csharp-sdk/pagination/custom-pager-with-exception-handler/src/SeedPagination/Core/JsonConfiguration.cs index e94f84af24ed..a52fab0b810a 100644 --- a/seed/csharp-sdk/pagination/custom-pager-with-exception-handler/src/SeedPagination/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/pagination/custom-pager-with-exception-handler/src/SeedPagination/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/pagination/custom-pager-with-exception-handler/src/SeedPagination/Core/NullableAttribute.cs b/seed/csharp-sdk/pagination/custom-pager-with-exception-handler/src/SeedPagination/Core/NullableAttribute.cs new file mode 100644 index 000000000000..16772890c48e --- /dev/null +++ b/seed/csharp-sdk/pagination/custom-pager-with-exception-handler/src/SeedPagination/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedPagination.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/pagination/custom-pager-with-exception-handler/src/SeedPagination/Core/Optional.cs b/seed/csharp-sdk/pagination/custom-pager-with-exception-handler/src/SeedPagination/Core/Optional.cs new file mode 100644 index 000000000000..a72436b1ae2e --- /dev/null +++ b/seed/csharp-sdk/pagination/custom-pager-with-exception-handler/src/SeedPagination/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedPagination.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/pagination/custom-pager-with-exception-handler/src/SeedPagination/Core/OptionalAttribute.cs b/seed/csharp-sdk/pagination/custom-pager-with-exception-handler/src/SeedPagination/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..483910b676e6 --- /dev/null +++ b/seed/csharp-sdk/pagination/custom-pager-with-exception-handler/src/SeedPagination/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedPagination.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/pagination/custom-pager-with-exception-handler/src/SeedPagination/Core/RawClient.cs b/seed/csharp-sdk/pagination/custom-pager-with-exception-handler/src/SeedPagination/Core/RawClient.cs index d4a10e55c304..ce1c3cf089ea 100644 --- a/seed/csharp-sdk/pagination/custom-pager-with-exception-handler/src/SeedPagination/Core/RawClient.cs +++ b/seed/csharp-sdk/pagination/custom-pager-with-exception-handler/src/SeedPagination/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/pagination/custom-pager/src/SeedPagination.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/pagination/custom-pager/src/SeedPagination.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/pagination/custom-pager/src/SeedPagination.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/pagination/custom-pager/src/SeedPagination.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/pagination/custom-pager/src/SeedPagination.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/pagination/custom-pager/src/SeedPagination.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..a7ce652c2dbb --- /dev/null +++ b/seed/csharp-sdk/pagination/custom-pager/src/SeedPagination.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedPagination.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/pagination/custom-pager/src/SeedPagination/Core/JsonConfiguration.cs b/seed/csharp-sdk/pagination/custom-pager/src/SeedPagination/Core/JsonConfiguration.cs index e94f84af24ed..a52fab0b810a 100644 --- a/seed/csharp-sdk/pagination/custom-pager/src/SeedPagination/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/pagination/custom-pager/src/SeedPagination/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/pagination/custom-pager/src/SeedPagination/Core/NullableAttribute.cs b/seed/csharp-sdk/pagination/custom-pager/src/SeedPagination/Core/NullableAttribute.cs new file mode 100644 index 000000000000..16772890c48e --- /dev/null +++ b/seed/csharp-sdk/pagination/custom-pager/src/SeedPagination/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedPagination.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/pagination/custom-pager/src/SeedPagination/Core/Optional.cs b/seed/csharp-sdk/pagination/custom-pager/src/SeedPagination/Core/Optional.cs new file mode 100644 index 000000000000..a72436b1ae2e --- /dev/null +++ b/seed/csharp-sdk/pagination/custom-pager/src/SeedPagination/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedPagination.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/pagination/custom-pager/src/SeedPagination/Core/OptionalAttribute.cs b/seed/csharp-sdk/pagination/custom-pager/src/SeedPagination/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..483910b676e6 --- /dev/null +++ b/seed/csharp-sdk/pagination/custom-pager/src/SeedPagination/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedPagination.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/pagination/custom-pager/src/SeedPagination/Core/RawClient.cs b/seed/csharp-sdk/pagination/custom-pager/src/SeedPagination/Core/RawClient.cs index d4a10e55c304..ce1c3cf089ea 100644 --- a/seed/csharp-sdk/pagination/custom-pager/src/SeedPagination/Core/RawClient.cs +++ b/seed/csharp-sdk/pagination/custom-pager/src/SeedPagination/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/pagination/no-custom-config/src/SeedPagination.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/pagination/no-custom-config/src/SeedPagination.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/pagination/no-custom-config/src/SeedPagination.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/pagination/no-custom-config/src/SeedPagination.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/pagination/no-custom-config/src/SeedPagination.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/pagination/no-custom-config/src/SeedPagination.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..a7ce652c2dbb --- /dev/null +++ b/seed/csharp-sdk/pagination/no-custom-config/src/SeedPagination.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedPagination.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/pagination/no-custom-config/src/SeedPagination/Core/JsonConfiguration.cs b/seed/csharp-sdk/pagination/no-custom-config/src/SeedPagination/Core/JsonConfiguration.cs index e94f84af24ed..a52fab0b810a 100644 --- a/seed/csharp-sdk/pagination/no-custom-config/src/SeedPagination/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/pagination/no-custom-config/src/SeedPagination/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/pagination/no-custom-config/src/SeedPagination/Core/NullableAttribute.cs b/seed/csharp-sdk/pagination/no-custom-config/src/SeedPagination/Core/NullableAttribute.cs new file mode 100644 index 000000000000..16772890c48e --- /dev/null +++ b/seed/csharp-sdk/pagination/no-custom-config/src/SeedPagination/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedPagination.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/pagination/no-custom-config/src/SeedPagination/Core/Optional.cs b/seed/csharp-sdk/pagination/no-custom-config/src/SeedPagination/Core/Optional.cs new file mode 100644 index 000000000000..a72436b1ae2e --- /dev/null +++ b/seed/csharp-sdk/pagination/no-custom-config/src/SeedPagination/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedPagination.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/pagination/no-custom-config/src/SeedPagination/Core/OptionalAttribute.cs b/seed/csharp-sdk/pagination/no-custom-config/src/SeedPagination/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..483910b676e6 --- /dev/null +++ b/seed/csharp-sdk/pagination/no-custom-config/src/SeedPagination/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedPagination.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/pagination/no-custom-config/src/SeedPagination/Core/RawClient.cs b/seed/csharp-sdk/pagination/no-custom-config/src/SeedPagination/Core/RawClient.cs index d4a10e55c304..ce1c3cf089ea 100644 --- a/seed/csharp-sdk/pagination/no-custom-config/src/SeedPagination/Core/RawClient.cs +++ b/seed/csharp-sdk/pagination/no-custom-config/src/SeedPagination/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/path-parameters/no-custom-config/src/SeedPathParameters.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/path-parameters/no-custom-config/src/SeedPathParameters.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/path-parameters/no-custom-config/src/SeedPathParameters.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/path-parameters/no-custom-config/src/SeedPathParameters.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/path-parameters/no-custom-config/src/SeedPathParameters.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/path-parameters/no-custom-config/src/SeedPathParameters.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..5c14addfe708 --- /dev/null +++ b/seed/csharp-sdk/path-parameters/no-custom-config/src/SeedPathParameters.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedPathParameters.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/path-parameters/no-custom-config/src/SeedPathParameters/Core/JsonConfiguration.cs b/seed/csharp-sdk/path-parameters/no-custom-config/src/SeedPathParameters/Core/JsonConfiguration.cs index f390bf55d08a..d7bcec496aa7 100644 --- a/seed/csharp-sdk/path-parameters/no-custom-config/src/SeedPathParameters/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/path-parameters/no-custom-config/src/SeedPathParameters/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/path-parameters/no-custom-config/src/SeedPathParameters/Core/NullableAttribute.cs b/seed/csharp-sdk/path-parameters/no-custom-config/src/SeedPathParameters/Core/NullableAttribute.cs new file mode 100644 index 000000000000..d93020dd039f --- /dev/null +++ b/seed/csharp-sdk/path-parameters/no-custom-config/src/SeedPathParameters/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedPathParameters.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/path-parameters/no-custom-config/src/SeedPathParameters/Core/Optional.cs b/seed/csharp-sdk/path-parameters/no-custom-config/src/SeedPathParameters/Core/Optional.cs new file mode 100644 index 000000000000..9cab4f8a18b5 --- /dev/null +++ b/seed/csharp-sdk/path-parameters/no-custom-config/src/SeedPathParameters/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedPathParameters.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/path-parameters/no-custom-config/src/SeedPathParameters/Core/OptionalAttribute.cs b/seed/csharp-sdk/path-parameters/no-custom-config/src/SeedPathParameters/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..9ed70ee5c74d --- /dev/null +++ b/seed/csharp-sdk/path-parameters/no-custom-config/src/SeedPathParameters/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedPathParameters.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/path-parameters/no-custom-config/src/SeedPathParameters/Core/RawClient.cs b/seed/csharp-sdk/path-parameters/no-custom-config/src/SeedPathParameters/Core/RawClient.cs index 8298fc5bb230..b092d19f30dc 100644 --- a/seed/csharp-sdk/path-parameters/no-custom-config/src/SeedPathParameters/Core/RawClient.cs +++ b/seed/csharp-sdk/path-parameters/no-custom-config/src/SeedPathParameters/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/path-parameters/no-inline-path-parameters/src/SeedPathParameters.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/path-parameters/no-inline-path-parameters/src/SeedPathParameters.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/path-parameters/no-inline-path-parameters/src/SeedPathParameters.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/path-parameters/no-inline-path-parameters/src/SeedPathParameters.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/path-parameters/no-inline-path-parameters/src/SeedPathParameters.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/path-parameters/no-inline-path-parameters/src/SeedPathParameters.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..5c14addfe708 --- /dev/null +++ b/seed/csharp-sdk/path-parameters/no-inline-path-parameters/src/SeedPathParameters.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedPathParameters.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/path-parameters/no-inline-path-parameters/src/SeedPathParameters/Core/JsonConfiguration.cs b/seed/csharp-sdk/path-parameters/no-inline-path-parameters/src/SeedPathParameters/Core/JsonConfiguration.cs index f390bf55d08a..d7bcec496aa7 100644 --- a/seed/csharp-sdk/path-parameters/no-inline-path-parameters/src/SeedPathParameters/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/path-parameters/no-inline-path-parameters/src/SeedPathParameters/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/path-parameters/no-inline-path-parameters/src/SeedPathParameters/Core/NullableAttribute.cs b/seed/csharp-sdk/path-parameters/no-inline-path-parameters/src/SeedPathParameters/Core/NullableAttribute.cs new file mode 100644 index 000000000000..d93020dd039f --- /dev/null +++ b/seed/csharp-sdk/path-parameters/no-inline-path-parameters/src/SeedPathParameters/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedPathParameters.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/path-parameters/no-inline-path-parameters/src/SeedPathParameters/Core/Optional.cs b/seed/csharp-sdk/path-parameters/no-inline-path-parameters/src/SeedPathParameters/Core/Optional.cs new file mode 100644 index 000000000000..9cab4f8a18b5 --- /dev/null +++ b/seed/csharp-sdk/path-parameters/no-inline-path-parameters/src/SeedPathParameters/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedPathParameters.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/path-parameters/no-inline-path-parameters/src/SeedPathParameters/Core/OptionalAttribute.cs b/seed/csharp-sdk/path-parameters/no-inline-path-parameters/src/SeedPathParameters/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..9ed70ee5c74d --- /dev/null +++ b/seed/csharp-sdk/path-parameters/no-inline-path-parameters/src/SeedPathParameters/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedPathParameters.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/path-parameters/no-inline-path-parameters/src/SeedPathParameters/Core/RawClient.cs b/seed/csharp-sdk/path-parameters/no-inline-path-parameters/src/SeedPathParameters/Core/RawClient.cs index 8298fc5bb230..b092d19f30dc 100644 --- a/seed/csharp-sdk/path-parameters/no-inline-path-parameters/src/SeedPathParameters/Core/RawClient.cs +++ b/seed/csharp-sdk/path-parameters/no-inline-path-parameters/src/SeedPathParameters/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/plain-text/src/SeedPlainText.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/plain-text/src/SeedPlainText.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/plain-text/src/SeedPlainText.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/plain-text/src/SeedPlainText.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/plain-text/src/SeedPlainText.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/plain-text/src/SeedPlainText.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..df15c8dd619d --- /dev/null +++ b/seed/csharp-sdk/plain-text/src/SeedPlainText.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedPlainText.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/plain-text/src/SeedPlainText/Core/JsonConfiguration.cs b/seed/csharp-sdk/plain-text/src/SeedPlainText/Core/JsonConfiguration.cs index 2bb96284e732..043213baaace 100644 --- a/seed/csharp-sdk/plain-text/src/SeedPlainText/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/plain-text/src/SeedPlainText/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/plain-text/src/SeedPlainText/Core/NullableAttribute.cs b/seed/csharp-sdk/plain-text/src/SeedPlainText/Core/NullableAttribute.cs new file mode 100644 index 000000000000..4919f2d6f043 --- /dev/null +++ b/seed/csharp-sdk/plain-text/src/SeedPlainText/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedPlainText.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/plain-text/src/SeedPlainText/Core/Optional.cs b/seed/csharp-sdk/plain-text/src/SeedPlainText/Core/Optional.cs new file mode 100644 index 000000000000..bfe7bb29e479 --- /dev/null +++ b/seed/csharp-sdk/plain-text/src/SeedPlainText/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedPlainText.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/plain-text/src/SeedPlainText/Core/OptionalAttribute.cs b/seed/csharp-sdk/plain-text/src/SeedPlainText/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..e1f31785c8a4 --- /dev/null +++ b/seed/csharp-sdk/plain-text/src/SeedPlainText/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedPlainText.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/plain-text/src/SeedPlainText/Core/RawClient.cs b/seed/csharp-sdk/plain-text/src/SeedPlainText/Core/RawClient.cs index fa5a70826749..e213e09b86ff 100644 --- a/seed/csharp-sdk/plain-text/src/SeedPlainText/Core/RawClient.cs +++ b/seed/csharp-sdk/plain-text/src/SeedPlainText/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/property-access/src/SeedPropertyAccess.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/property-access/src/SeedPropertyAccess.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/property-access/src/SeedPropertyAccess.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/property-access/src/SeedPropertyAccess.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/property-access/src/SeedPropertyAccess.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/property-access/src/SeedPropertyAccess.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..9d73078cca07 --- /dev/null +++ b/seed/csharp-sdk/property-access/src/SeedPropertyAccess.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedPropertyAccess.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/property-access/src/SeedPropertyAccess/Core/JsonConfiguration.cs b/seed/csharp-sdk/property-access/src/SeedPropertyAccess/Core/JsonConfiguration.cs index e6541b743082..c2168a867dd6 100644 --- a/seed/csharp-sdk/property-access/src/SeedPropertyAccess/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/property-access/src/SeedPropertyAccess/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/property-access/src/SeedPropertyAccess/Core/NullableAttribute.cs b/seed/csharp-sdk/property-access/src/SeedPropertyAccess/Core/NullableAttribute.cs new file mode 100644 index 000000000000..5c70c709a157 --- /dev/null +++ b/seed/csharp-sdk/property-access/src/SeedPropertyAccess/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedPropertyAccess.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/property-access/src/SeedPropertyAccess/Core/Optional.cs b/seed/csharp-sdk/property-access/src/SeedPropertyAccess/Core/Optional.cs new file mode 100644 index 000000000000..cd3d7cd70a40 --- /dev/null +++ b/seed/csharp-sdk/property-access/src/SeedPropertyAccess/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedPropertyAccess.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/property-access/src/SeedPropertyAccess/Core/OptionalAttribute.cs b/seed/csharp-sdk/property-access/src/SeedPropertyAccess/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..88f393fe932f --- /dev/null +++ b/seed/csharp-sdk/property-access/src/SeedPropertyAccess/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedPropertyAccess.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/property-access/src/SeedPropertyAccess/Core/RawClient.cs b/seed/csharp-sdk/property-access/src/SeedPropertyAccess/Core/RawClient.cs index c0299cf195fc..65e9cf784c5d 100644 --- a/seed/csharp-sdk/property-access/src/SeedPropertyAccess/Core/RawClient.cs +++ b/seed/csharp-sdk/property-access/src/SeedPropertyAccess/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/public-object/src/SeedPublicObject.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/public-object/src/SeedPublicObject.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/public-object/src/SeedPublicObject.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/public-object/src/SeedPublicObject.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/public-object/src/SeedPublicObject.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/public-object/src/SeedPublicObject.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..5d3a28e63e98 --- /dev/null +++ b/seed/csharp-sdk/public-object/src/SeedPublicObject.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedPublicObject.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/public-object/src/SeedPublicObject/Core/JsonConfiguration.cs b/seed/csharp-sdk/public-object/src/SeedPublicObject/Core/JsonConfiguration.cs index 4cfcde109036..ad84644f2dcc 100644 --- a/seed/csharp-sdk/public-object/src/SeedPublicObject/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/public-object/src/SeedPublicObject/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/public-object/src/SeedPublicObject/Core/NullableAttribute.cs b/seed/csharp-sdk/public-object/src/SeedPublicObject/Core/NullableAttribute.cs new file mode 100644 index 000000000000..a4b337b2d21c --- /dev/null +++ b/seed/csharp-sdk/public-object/src/SeedPublicObject/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedPublicObject.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/public-object/src/SeedPublicObject/Core/Optional.cs b/seed/csharp-sdk/public-object/src/SeedPublicObject/Core/Optional.cs new file mode 100644 index 000000000000..1e036a478e9d --- /dev/null +++ b/seed/csharp-sdk/public-object/src/SeedPublicObject/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedPublicObject.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/public-object/src/SeedPublicObject/Core/OptionalAttribute.cs b/seed/csharp-sdk/public-object/src/SeedPublicObject/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..acd67db32dc4 --- /dev/null +++ b/seed/csharp-sdk/public-object/src/SeedPublicObject/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedPublicObject.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/public-object/src/SeedPublicObject/Core/RawClient.cs b/seed/csharp-sdk/public-object/src/SeedPublicObject/Core/RawClient.cs index 169afbfc452c..de2dc72b44b5 100644 --- a/seed/csharp-sdk/public-object/src/SeedPublicObject/Core/RawClient.cs +++ b/seed/csharp-sdk/public-object/src/SeedPublicObject/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/query-parameters-openapi-as-objects/src/SeedApi.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/query-parameters-openapi-as-objects/src/SeedApi.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/query-parameters-openapi-as-objects/src/SeedApi.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/query-parameters-openapi-as-objects/src/SeedApi.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/query-parameters-openapi-as-objects/src/SeedApi.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/query-parameters-openapi-as-objects/src/SeedApi.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..dc732084c15f --- /dev/null +++ b/seed/csharp-sdk/query-parameters-openapi-as-objects/src/SeedApi.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedApi.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/query-parameters-openapi-as-objects/src/SeedApi/Core/JsonConfiguration.cs b/seed/csharp-sdk/query-parameters-openapi-as-objects/src/SeedApi/Core/JsonConfiguration.cs index 321193691456..df6b2c46945d 100644 --- a/seed/csharp-sdk/query-parameters-openapi-as-objects/src/SeedApi/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/query-parameters-openapi-as-objects/src/SeedApi/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/query-parameters-openapi-as-objects/src/SeedApi/Core/NullableAttribute.cs b/seed/csharp-sdk/query-parameters-openapi-as-objects/src/SeedApi/Core/NullableAttribute.cs new file mode 100644 index 000000000000..8fca30535a2a --- /dev/null +++ b/seed/csharp-sdk/query-parameters-openapi-as-objects/src/SeedApi/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedApi.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/query-parameters-openapi-as-objects/src/SeedApi/Core/Optional.cs b/seed/csharp-sdk/query-parameters-openapi-as-objects/src/SeedApi/Core/Optional.cs new file mode 100644 index 000000000000..4a9691feca1f --- /dev/null +++ b/seed/csharp-sdk/query-parameters-openapi-as-objects/src/SeedApi/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedApi.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/query-parameters-openapi-as-objects/src/SeedApi/Core/OptionalAttribute.cs b/seed/csharp-sdk/query-parameters-openapi-as-objects/src/SeedApi/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..b2bc05f7244f --- /dev/null +++ b/seed/csharp-sdk/query-parameters-openapi-as-objects/src/SeedApi/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedApi.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/query-parameters-openapi-as-objects/src/SeedApi/Core/RawClient.cs b/seed/csharp-sdk/query-parameters-openapi-as-objects/src/SeedApi/Core/RawClient.cs index 72e34bbc0e89..b60968e4eb03 100644 --- a/seed/csharp-sdk/query-parameters-openapi-as-objects/src/SeedApi/Core/RawClient.cs +++ b/seed/csharp-sdk/query-parameters-openapi-as-objects/src/SeedApi/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/query-parameters-openapi/src/SeedApi.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/query-parameters-openapi/src/SeedApi.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/query-parameters-openapi/src/SeedApi.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/query-parameters-openapi/src/SeedApi.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/query-parameters-openapi/src/SeedApi.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/query-parameters-openapi/src/SeedApi.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..dc732084c15f --- /dev/null +++ b/seed/csharp-sdk/query-parameters-openapi/src/SeedApi.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedApi.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/query-parameters-openapi/src/SeedApi/Core/JsonConfiguration.cs b/seed/csharp-sdk/query-parameters-openapi/src/SeedApi/Core/JsonConfiguration.cs index 321193691456..df6b2c46945d 100644 --- a/seed/csharp-sdk/query-parameters-openapi/src/SeedApi/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/query-parameters-openapi/src/SeedApi/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/query-parameters-openapi/src/SeedApi/Core/NullableAttribute.cs b/seed/csharp-sdk/query-parameters-openapi/src/SeedApi/Core/NullableAttribute.cs new file mode 100644 index 000000000000..8fca30535a2a --- /dev/null +++ b/seed/csharp-sdk/query-parameters-openapi/src/SeedApi/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedApi.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/query-parameters-openapi/src/SeedApi/Core/Optional.cs b/seed/csharp-sdk/query-parameters-openapi/src/SeedApi/Core/Optional.cs new file mode 100644 index 000000000000..4a9691feca1f --- /dev/null +++ b/seed/csharp-sdk/query-parameters-openapi/src/SeedApi/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedApi.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/query-parameters-openapi/src/SeedApi/Core/OptionalAttribute.cs b/seed/csharp-sdk/query-parameters-openapi/src/SeedApi/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..b2bc05f7244f --- /dev/null +++ b/seed/csharp-sdk/query-parameters-openapi/src/SeedApi/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedApi.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/query-parameters-openapi/src/SeedApi/Core/RawClient.cs b/seed/csharp-sdk/query-parameters-openapi/src/SeedApi/Core/RawClient.cs index 72e34bbc0e89..b60968e4eb03 100644 --- a/seed/csharp-sdk/query-parameters-openapi/src/SeedApi/Core/RawClient.cs +++ b/seed/csharp-sdk/query-parameters-openapi/src/SeedApi/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/query-parameters/src/SeedQueryParameters.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/query-parameters/src/SeedQueryParameters.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/query-parameters/src/SeedQueryParameters.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/query-parameters/src/SeedQueryParameters.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/query-parameters/src/SeedQueryParameters.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/query-parameters/src/SeedQueryParameters.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..a0e33beead1d --- /dev/null +++ b/seed/csharp-sdk/query-parameters/src/SeedQueryParameters.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedQueryParameters.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/query-parameters/src/SeedQueryParameters/Core/JsonConfiguration.cs b/seed/csharp-sdk/query-parameters/src/SeedQueryParameters/Core/JsonConfiguration.cs index efce6eb8be40..7d710421968e 100644 --- a/seed/csharp-sdk/query-parameters/src/SeedQueryParameters/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/query-parameters/src/SeedQueryParameters/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/query-parameters/src/SeedQueryParameters/Core/NullableAttribute.cs b/seed/csharp-sdk/query-parameters/src/SeedQueryParameters/Core/NullableAttribute.cs new file mode 100644 index 000000000000..a18d8fd6f293 --- /dev/null +++ b/seed/csharp-sdk/query-parameters/src/SeedQueryParameters/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedQueryParameters.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/query-parameters/src/SeedQueryParameters/Core/Optional.cs b/seed/csharp-sdk/query-parameters/src/SeedQueryParameters/Core/Optional.cs new file mode 100644 index 000000000000..63bd1c2e7124 --- /dev/null +++ b/seed/csharp-sdk/query-parameters/src/SeedQueryParameters/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedQueryParameters.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/query-parameters/src/SeedQueryParameters/Core/OptionalAttribute.cs b/seed/csharp-sdk/query-parameters/src/SeedQueryParameters/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..efb881135b58 --- /dev/null +++ b/seed/csharp-sdk/query-parameters/src/SeedQueryParameters/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedQueryParameters.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/query-parameters/src/SeedQueryParameters/Core/RawClient.cs b/seed/csharp-sdk/query-parameters/src/SeedQueryParameters/Core/RawClient.cs index c1c92f80d091..df8eac8c82c2 100644 --- a/seed/csharp-sdk/query-parameters/src/SeedQueryParameters/Core/RawClient.cs +++ b/seed/csharp-sdk/query-parameters/src/SeedQueryParameters/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/request-parameters/src/SeedRequestParameters.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/request-parameters/src/SeedRequestParameters.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/request-parameters/src/SeedRequestParameters.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/request-parameters/src/SeedRequestParameters.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/request-parameters/src/SeedRequestParameters.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/request-parameters/src/SeedRequestParameters.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..f5aef3d35f8c --- /dev/null +++ b/seed/csharp-sdk/request-parameters/src/SeedRequestParameters.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedRequestParameters.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/request-parameters/src/SeedRequestParameters/Core/JsonConfiguration.cs b/seed/csharp-sdk/request-parameters/src/SeedRequestParameters/Core/JsonConfiguration.cs index e140a5bf996c..4c51c9e1dd49 100644 --- a/seed/csharp-sdk/request-parameters/src/SeedRequestParameters/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/request-parameters/src/SeedRequestParameters/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/request-parameters/src/SeedRequestParameters/Core/NullableAttribute.cs b/seed/csharp-sdk/request-parameters/src/SeedRequestParameters/Core/NullableAttribute.cs new file mode 100644 index 000000000000..fdafd73f516e --- /dev/null +++ b/seed/csharp-sdk/request-parameters/src/SeedRequestParameters/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedRequestParameters.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/request-parameters/src/SeedRequestParameters/Core/Optional.cs b/seed/csharp-sdk/request-parameters/src/SeedRequestParameters/Core/Optional.cs new file mode 100644 index 000000000000..6d9cba563488 --- /dev/null +++ b/seed/csharp-sdk/request-parameters/src/SeedRequestParameters/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedRequestParameters.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/request-parameters/src/SeedRequestParameters/Core/OptionalAttribute.cs b/seed/csharp-sdk/request-parameters/src/SeedRequestParameters/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..1213e76eefa1 --- /dev/null +++ b/seed/csharp-sdk/request-parameters/src/SeedRequestParameters/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedRequestParameters.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/request-parameters/src/SeedRequestParameters/Core/RawClient.cs b/seed/csharp-sdk/request-parameters/src/SeedRequestParameters/Core/RawClient.cs index 2663ab06c468..d2ffaac5c296 100644 --- a/seed/csharp-sdk/request-parameters/src/SeedRequestParameters/Core/RawClient.cs +++ b/seed/csharp-sdk/request-parameters/src/SeedRequestParameters/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/required-nullable/explicit-nullable-optional/.editorconfig b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/.editorconfig new file mode 100644 index 000000000000..1e7a0adbac80 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/.editorconfig @@ -0,0 +1,35 @@ +root = true + +[*.cs] +resharper_arrange_object_creation_when_type_evident_highlighting = hint +resharper_auto_property_can_be_made_get_only_global_highlighting = hint +resharper_check_namespace_highlighting = hint +resharper_class_never_instantiated_global_highlighting = hint +resharper_class_never_instantiated_local_highlighting = hint +resharper_collection_never_updated_global_highlighting = hint +resharper_convert_type_check_pattern_to_null_check_highlighting = hint +resharper_inconsistent_naming_highlighting = hint +resharper_member_can_be_private_global_highlighting = hint +resharper_member_hides_static_from_outer_class_highlighting = hint +resharper_not_accessed_field_local_highlighting = hint +resharper_nullable_warning_suppression_is_used_highlighting = suggestion +resharper_partial_type_with_single_part_highlighting = hint +resharper_prefer_concrete_value_over_default_highlighting = none +resharper_private_field_can_be_converted_to_local_variable_highlighting = hint +resharper_property_can_be_made_init_only_global_highlighting = hint +resharper_property_can_be_made_init_only_local_highlighting = hint +resharper_redundant_name_qualifier_highlighting = none +resharper_redundant_using_directive_highlighting = hint +resharper_replace_slice_with_range_indexer_highlighting = none +resharper_unused_auto_property_accessor_global_highlighting = hint +resharper_unused_auto_property_accessor_local_highlighting = hint +resharper_unused_member_global_highlighting = hint +resharper_unused_type_global_highlighting = hint +resharper_use_string_interpolation_highlighting = hint +dotnet_diagnostic.CS1591.severity = suggestion + +[src/**/Types/*.cs] +resharper_check_namespace_highlighting = none + +[src/**/Core/Public/*.cs] +resharper_check_namespace_highlighting = none \ No newline at end of file diff --git a/seed/csharp-sdk/required-nullable/explicit-nullable-optional/.fern/metadata.json b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/.fern/metadata.json new file mode 100644 index 000000000000..9a812ae689b2 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/.fern/metadata.json @@ -0,0 +1,9 @@ +{ + "cliVersion": "DUMMY", + "generatorName": "fernapi/fern-csharp-sdk", + "generatorVersion": "latest", + "generatorConfig": { + "experimental-explicit-nullable-optional": true + }, + "sdkVersion": "0.0.1" +} \ No newline at end of file diff --git a/seed/csharp-sdk/required-nullable/.github/workflows/ci.yml b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/.github/workflows/ci.yml similarity index 100% rename from seed/csharp-sdk/required-nullable/.github/workflows/ci.yml rename to seed/csharp-sdk/required-nullable/explicit-nullable-optional/.github/workflows/ci.yml diff --git a/seed/csharp-sdk/required-nullable/explicit-nullable-optional/.gitignore b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/.gitignore new file mode 100644 index 000000000000..11014f2b33d7 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/.gitignore @@ -0,0 +1,484 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +## This is based on `dotnet new gitignore` and customized by Fern + +# dotenv files +.env + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +# [Rr]elease/ (Ignored by Fern) +# [Rr]eleases/ (Ignored by Fern) +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +# [Ll]og/ (Ignored by Fern) +# [Ll]ogs/ (Ignored by Fern) + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET +project.lock.json +project.fragment.lock.json +artifacts/ + +# Tye +.tye/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml +.idea + +## +## Visual studio for Mac +## + + +# globs +Makefile.in +*.userprefs +*.usertasks +config.make +config.status +aclocal.m4 +install-sh +autom4te.cache/ +*.tar.gz +tarballs/ +test-results/ + +# Mac bundle stuff +*.dmg +*.app + +# content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# Vim temporary swap files +*.swp diff --git a/seed/csharp-sdk/required-nullable/explicit-nullable-optional/README.md b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/README.md new file mode 100644 index 000000000000..ac9b63b1eab3 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/README.md @@ -0,0 +1,113 @@ +# Seed C# Library + +[![fern shield](https://img.shields.io/badge/%F0%9F%8C%BF-Built%20with%20Fern-brightgreen)](https://buildwithfern.com?utm_source=github&utm_medium=github&utm_campaign=readme&utm_source=Seed%2FC%23) +[![nuget shield](https://img.shields.io/nuget/v/SeedApi)](https://nuget.org/packages/SeedApi) + +The Seed C# library provides convenient access to the Seed APIs from C#. + +## Table of Contents + +- [Requirements](#requirements) +- [Installation](#installation) +- [Reference](#reference) +- [Usage](#usage) +- [Exception Handling](#exception-handling) +- [Advanced](#advanced) + - [Retries](#retries) + - [Timeouts](#timeouts) +- [Contributing](#contributing) + +## Requirements + +This SDK requires: + +## Installation + +```sh +dotnet add package SeedApi +``` + +## Reference + +A full reference for this library is available [here](./reference.md). + +## Usage + +Instantiate and use the client with the following: + +```csharp +using SeedApi; + +var client = new SeedApiClient(); +await client.GetFooAsync( + new GetFooRequest + { + RequiredBaz = "required_baz", + RequiredNullableBaz = "required_nullable_baz", + } +); +``` + +## Exception Handling + +When the API returns a non-success status code (4xx or 5xx response), a subclass of the following error +will be thrown. + +```csharp +using SeedApi; + +try { + var response = await client.GetFooAsync(...); +} catch (SeedApiApiException e) { + System.Console.WriteLine(e.Body); + System.Console.WriteLine(e.StatusCode); +} +``` + +## Advanced + +### Retries + +The SDK is instrumented with automatic retries with exponential backoff. A request will be retried as long +as the request is deemed retryable and the number of retry attempts has not grown larger than the configured +retry limit (default: 2). + +A request is deemed retryable when any of the following HTTP status codes is returned: + +- [408](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) (Timeout) +- [429](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) (Too Many Requests) +- [5XX](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500) (Internal Server Errors) + +Use the `MaxRetries` request option to configure this behavior. + +```csharp +var response = await client.GetFooAsync( + ..., + new RequestOptions { + MaxRetries: 0 // Override MaxRetries at the request level + } +); +``` + +### Timeouts + +The SDK defaults to a 30 second timeout. Use the `Timeout` option to configure this behavior. + +```csharp +var response = await client.GetFooAsync( + ..., + new RequestOptions { + Timeout: TimeSpan.FromSeconds(3) // Override timeout to 3s + } +); +``` + +## Contributing + +While we value open-source contributions to this SDK, this library is generated programmatically. +Additions made directly to this library would have to be moved over to our generation code, +otherwise they would be overwritten upon the next generated release. Feel free to open a PR as +a proof of concept, but know that we will not be able to merge it as-is. We suggest opening +an issue first to discuss with us! + +On the other hand, contributions to the README are always very welcome! \ No newline at end of file diff --git a/seed/csharp-sdk/required-nullable/explicit-nullable-optional/SeedApi.slnx b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/SeedApi.slnx new file mode 100644 index 000000000000..d4c63c241aad --- /dev/null +++ b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/SeedApi.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/seed/csharp-sdk/required-nullable/explicit-nullable-optional/reference.md b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/reference.md new file mode 100644 index 000000000000..6ca4c16b5d62 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/reference.md @@ -0,0 +1,103 @@ +# Reference +
client.GetFooAsync(GetFooRequest { ... }) -> Foo +
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```csharp +await client.GetFooAsync( + new GetFooRequest + { + RequiredBaz = "required_baz", + RequiredNullableBaz = "required_nullable_baz", + } +); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request:** `GetFooRequest` + +
+
+
+
+ + +
+
+
+ +
client.UpdateFooAsync(id, UpdateFooRequest { ... }) -> Foo +
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```csharp +await client.UpdateFooAsync( + "id", + new UpdateFooRequest + { + XIdempotencyKey = "X-Idempotency-Key", + NullableText = "nullable_text", + NullableNumber = 1.1, + NonNullableText = "non_nullable_text", + } +); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**id:** `string` + +
+
+ +
+
+ +**request:** `UpdateFooRequest` + +
+
+
+
+ + +
+
+
diff --git a/seed/csharp-sdk/required-nullable/explicit-nullable-optional/snippet.json b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/snippet.json new file mode 100644 index 000000000000..c065f84177ab --- /dev/null +++ b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/snippet.json @@ -0,0 +1,29 @@ +{ + "types": {}, + "endpoints": [ + { + "example_identifier": null, + "id": { + "path": "/foo", + "method": "GET", + "identifier_override": "endpoint_.getFoo" + }, + "snippet": { + "type": "csharp", + "client": "using SeedApi;\n\nvar client = new SeedApiClient();\nawait client.GetFooAsync(\n new GetFooRequest\n {\n RequiredBaz = \"required_baz\",\n RequiredNullableBaz = \"required_nullable_baz\",\n }\n);\n" + } + }, + { + "example_identifier": null, + "id": { + "path": "/foo/{id}", + "method": "PATCH", + "identifier_override": "endpoint_.updateFoo" + }, + "snippet": { + "type": "csharp", + "client": "using SeedApi;\n\nvar client = new SeedApiClient();\nawait client.UpdateFooAsync(\n \"id\",\n new UpdateFooRequest\n {\n XIdempotencyKey = \"X-Idempotency-Key\",\n NullableText = \"nullable_text\",\n NullableNumber = 1.1,\n NonNullableText = \"non_nullable_text\",\n }\n);\n" + } + } + ] +} \ No newline at end of file diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi.DynamicSnippets/Example0.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.DynamicSnippets/Example0.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi.DynamicSnippets/Example0.cs rename to seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.DynamicSnippets/Example0.cs diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi.DynamicSnippets/Example1.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.DynamicSnippets/Example1.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi.DynamicSnippets/Example1.cs rename to seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.DynamicSnippets/Example1.cs diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi.DynamicSnippets/Example2.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.DynamicSnippets/Example2.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi.DynamicSnippets/Example2.cs rename to seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.DynamicSnippets/Example2.cs diff --git a/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.DynamicSnippets/SeedApi.DynamicSnippets.csproj b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.DynamicSnippets/SeedApi.DynamicSnippets.csproj new file mode 100644 index 000000000000..3417db2e58e2 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.DynamicSnippets/SeedApi.DynamicSnippets.csproj @@ -0,0 +1,13 @@ + + + + net8.0 + 12 + enable + enable + + + + + + \ No newline at end of file diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi.Test/Core/Json/AdditionalPropertiesTests.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.Test/Core/Json/AdditionalPropertiesTests.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi.Test/Core/Json/AdditionalPropertiesTests.cs rename to seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.Test/Core/Json/AdditionalPropertiesTests.cs diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi.Test/Core/Json/DateOnlyJsonTests.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.Test/Core/Json/DateOnlyJsonTests.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi.Test/Core/Json/DateOnlyJsonTests.cs rename to seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.Test/Core/Json/DateOnlyJsonTests.cs diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi.Test/Core/Json/DateTimeJsonTests.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.Test/Core/Json/DateTimeJsonTests.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi.Test/Core/Json/DateTimeJsonTests.cs rename to seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.Test/Core/Json/DateTimeJsonTests.cs diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi.Test/Core/Json/JsonAccessAttributeTests.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.Test/Core/Json/JsonAccessAttributeTests.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi.Test/Core/Json/JsonAccessAttributeTests.cs rename to seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.Test/Core/Json/JsonAccessAttributeTests.cs diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi.Test/Core/Json/OneOfSerializerTests.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.Test/Core/Json/OneOfSerializerTests.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi.Test/Core/Json/OneOfSerializerTests.cs rename to seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.Test/Core/Json/OneOfSerializerTests.cs diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi.Test/Core/Json/StringEnumSerializerTests.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.Test/Core/Json/StringEnumSerializerTests.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi.Test/Core/Json/StringEnumSerializerTests.cs rename to seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.Test/Core/Json/StringEnumSerializerTests.cs diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi.Test/Core/QueryStringConverterTests.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.Test/Core/QueryStringConverterTests.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi.Test/Core/QueryStringConverterTests.cs rename to seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.Test/Core/QueryStringConverterTests.cs diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi.Test/Core/RawClientTests/AdditionalHeadersTests.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.Test/Core/RawClientTests/AdditionalHeadersTests.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi.Test/Core/RawClientTests/AdditionalHeadersTests.cs rename to seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.Test/Core/RawClientTests/AdditionalHeadersTests.cs diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi.Test/Core/RawClientTests/AdditionalParametersTests.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.Test/Core/RawClientTests/AdditionalParametersTests.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi.Test/Core/RawClientTests/AdditionalParametersTests.cs rename to seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.Test/Core/RawClientTests/AdditionalParametersTests.cs diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi.Test/Core/RawClientTests/MultipartFormTests.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.Test/Core/RawClientTests/MultipartFormTests.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi.Test/Core/RawClientTests/MultipartFormTests.cs rename to seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.Test/Core/RawClientTests/MultipartFormTests.cs diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi.Test/Core/RawClientTests/QueryParameterTests.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.Test/Core/RawClientTests/QueryParameterTests.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi.Test/Core/RawClientTests/QueryParameterTests.cs rename to seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.Test/Core/RawClientTests/QueryParameterTests.cs diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi.Test/Core/RawClientTests/RetriesTests.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.Test/Core/RawClientTests/RetriesTests.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi.Test/Core/RawClientTests/RetriesTests.cs rename to seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.Test/Core/RawClientTests/RetriesTests.cs diff --git a/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.Test/SeedApi.Test.Custom.props b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.Test/SeedApi.Test.Custom.props new file mode 100644 index 000000000000..aac9b5020d80 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.Test/SeedApi.Test.Custom.props @@ -0,0 +1,6 @@ + + diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi.Test/SeedApi.Test.csproj b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.Test/SeedApi.Test.csproj similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi.Test/SeedApi.Test.csproj rename to seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.Test/SeedApi.Test.csproj diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi.Test/TestClient.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.Test/TestClient.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi.Test/TestClient.cs rename to seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.Test/TestClient.cs diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi.Test/Unit/MockServer/BaseMockServerTest.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.Test/Unit/MockServer/BaseMockServerTest.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi.Test/Unit/MockServer/BaseMockServerTest.cs rename to seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.Test/Unit/MockServer/BaseMockServerTest.cs diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi.Test/Unit/MockServer/GetFooTest.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.Test/Unit/MockServer/GetFooTest.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi.Test/Unit/MockServer/GetFooTest.cs rename to seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.Test/Unit/MockServer/GetFooTest.cs diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi.Test/Unit/MockServer/UpdateFooTest.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.Test/Unit/MockServer/UpdateFooTest.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi.Test/Unit/MockServer/UpdateFooTest.cs rename to seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.Test/Unit/MockServer/UpdateFooTest.cs diff --git a/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.Test/Utils/JsonElementComparer.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.Test/Utils/JsonElementComparer.cs new file mode 100644 index 000000000000..1704c99af443 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.Test/Utils/JsonElementComparer.cs @@ -0,0 +1,236 @@ +using System.Text.Json; +using NUnit.Framework.Constraints; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle JsonElement objects. +/// +public static class JsonElementComparerExtensions +{ + /// + /// Extension method for comparing JsonElement objects in NUnit tests. + /// Property order doesn't matter, but array order does matter. + /// Includes special handling for DateTime string formats. + /// + /// The Is.EqualTo() constraint instance. + /// A constraint that can compare JsonElements with detailed diffs. + public static EqualConstraint UsingJsonElementComparer(this EqualConstraint constraint) + { + return constraint.Using(new JsonElementComparer()); + } +} + +/// +/// Equality comparer for JsonElement with detailed reporting. +/// Property order doesn't matter, but array order does matter. +/// Now includes special handling for DateTime string formats with improved null handling. +/// +public class JsonElementComparer : IEqualityComparer +{ + private string _failurePath = string.Empty; + + /// + public bool Equals(JsonElement x, JsonElement y) + { + _failurePath = string.Empty; + return CompareJsonElements(x, y, string.Empty); + } + + /// + public int GetHashCode(JsonElement obj) + { + return JsonSerializer.Serialize(obj).GetHashCode(); + } + + private bool CompareJsonElements(JsonElement x, JsonElement y, string path) + { + // If value kinds don't match, they're not equivalent + if (x.ValueKind != y.ValueKind) + { + _failurePath = $"{path}: Expected {x.ValueKind} but got {y.ValueKind}"; + return false; + } + + switch (x.ValueKind) + { + case JsonValueKind.Object: + return CompareJsonObjects(x, y, path); + + case JsonValueKind.Array: + return CompareJsonArraysInOrder(x, y, path); + + case JsonValueKind.String: + string? xStr = x.GetString(); + string? yStr = y.GetString(); + + // Handle null strings + if (xStr is null && yStr is null) + return true; + + if (xStr is null || yStr is null) + { + _failurePath = + $"{path}: Expected {(xStr is null ? "null" : $"\"{xStr}\"")} but got {(yStr is null ? "null" : $"\"{yStr}\"")}"; + return false; + } + + // Check if they are identical strings + if (xStr == yStr) + return true; + + // Try to handle DateTime strings + if (IsLikelyDateTimeString(xStr) && IsLikelyDateTimeString(yStr)) + { + if (AreEquivalentDateTimeStrings(xStr, yStr)) + return true; + } + + _failurePath = $"{path}: Expected \"{xStr}\" but got \"{yStr}\""; + return false; + + case JsonValueKind.Number: + if (x.GetDecimal() != y.GetDecimal()) + { + _failurePath = $"{path}: Expected {x.GetDecimal()} but got {y.GetDecimal()}"; + return false; + } + + return true; + + case JsonValueKind.True: + case JsonValueKind.False: + if (x.GetBoolean() != y.GetBoolean()) + { + _failurePath = $"{path}: Expected {x.GetBoolean()} but got {y.GetBoolean()}"; + return false; + } + + return true; + + case JsonValueKind.Null: + return true; + + default: + _failurePath = $"{path}: Unsupported JsonValueKind {x.ValueKind}"; + return false; + } + } + + private bool IsLikelyDateTimeString(string? str) + { + // Simple heuristic to identify likely ISO date time strings + return str != null + && (str.Contains("T") && (str.EndsWith("Z") || str.Contains("+") || str.Contains("-"))); + } + + private bool AreEquivalentDateTimeStrings(string str1, string str2) + { + // Try to parse both as DateTime + if (DateTime.TryParse(str1, out DateTime dt1) && DateTime.TryParse(str2, out DateTime dt2)) + { + return dt1 == dt2; + } + + return false; + } + + private bool CompareJsonObjects(JsonElement x, JsonElement y, string path) + { + // Create dictionaries for both JSON objects + var xProps = new Dictionary(); + var yProps = new Dictionary(); + + foreach (var prop in x.EnumerateObject()) + xProps[prop.Name] = prop.Value; + + foreach (var prop in y.EnumerateObject()) + yProps[prop.Name] = prop.Value; + + // Check if all properties in x exist in y + foreach (var key in xProps.Keys) + { + if (!yProps.ContainsKey(key)) + { + _failurePath = $"{path}: Missing property '{key}'"; + return false; + } + } + + // Check if y has extra properties + foreach (var key in yProps.Keys) + { + if (!xProps.ContainsKey(key)) + { + _failurePath = $"{path}: Unexpected property '{key}'"; + return false; + } + } + + // Compare each property value + foreach (var key in xProps.Keys) + { + var propPath = string.IsNullOrEmpty(path) ? key : $"{path}.{key}"; + if (!CompareJsonElements(xProps[key], yProps[key], propPath)) + { + return false; + } + } + + return true; + } + + private bool CompareJsonArraysInOrder(JsonElement x, JsonElement y, string path) + { + var xArray = x.EnumerateArray(); + var yArray = y.EnumerateArray(); + + // Count x elements + var xCount = 0; + var xElements = new List(); + foreach (var item in xArray) + { + xElements.Add(item); + xCount++; + } + + // Count y elements + var yCount = 0; + var yElements = new List(); + foreach (var item in yArray) + { + yElements.Add(item); + yCount++; + } + + // Check if counts match + if (xCount != yCount) + { + _failurePath = $"{path}: Expected {xCount} items but found {yCount}"; + return false; + } + + // Compare elements in order + for (var i = 0; i < xCount; i++) + { + var itemPath = $"{path}[{i}]"; + if (!CompareJsonElements(xElements[i], yElements[i], itemPath)) + { + return false; + } + } + + return true; + } + + /// + public override string ToString() + { + if (!string.IsNullOrEmpty(_failurePath)) + { + return $"JSON comparison failed at {_failurePath}"; + } + + return "JsonElementEqualityComparer"; + } +} diff --git a/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.Test/Utils/NUnitExtensions.cs new file mode 100644 index 000000000000..78e90e0a90fc --- /dev/null +++ b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.Test/Utils/NUnitExtensions.cs @@ -0,0 +1,29 @@ +using NUnit.Framework.Constraints; + +namespace NUnit.Framework; + +/// +/// Extensions for NUnit constraints. +/// +public static class NUnitExtensions +{ + /// + /// Modifies the EqualConstraint to use our own set of default comparers. + /// + /// + /// + public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => + constraint + .UsingPropertiesComparer() + .UsingReadOnlyMemoryComparer() + .UsingReadOnlyMemoryComparer() + .UsingReadOnlyMemoryComparer() + .UsingReadOnlyMemoryComparer() + .UsingReadOnlyMemoryComparer() + .UsingReadOnlyMemoryComparer() + .UsingReadOnlyMemoryComparer() + .UsingReadOnlyMemoryComparer() + .UsingOneOfComparer() + .UsingJsonElementComparer() + .UsingOptionalComparer(); +} diff --git a/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.Test/Utils/OneOfComparer.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.Test/Utils/OneOfComparer.cs new file mode 100644 index 000000000000..0c975b471ff3 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.Test/Utils/OneOfComparer.cs @@ -0,0 +1,43 @@ +using NUnit.Framework.Constraints; +using OneOf; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle OneOf values. +/// +public static class EqualConstraintExtensions +{ + /// + /// Modifies the EqualConstraint to handle OneOf instances by comparing their inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOneOfComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOneOf types + constraint.Using( + (x, y) => + { + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (x.Value is null && y.Value is null) + { + return true; + } + + if (x.Value is null) + { + return false; + } + + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(x.Value, y.Value, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..dc732084c15f --- /dev/null +++ b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedApi.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.Test/Utils/ReadOnlyMemoryComparer.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.Test/Utils/ReadOnlyMemoryComparer.cs new file mode 100644 index 000000000000..fc0b595a5e54 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi.Test/Utils/ReadOnlyMemoryComparer.cs @@ -0,0 +1,87 @@ +using NUnit.Framework.Constraints; + +namespace NUnit.Framework; + +/// +/// Extensions for NUnit constraints. +/// +public static class ReadOnlyMemoryComparerExtensions +{ + /// + /// Extension method for comparing ReadOnlyMemory<T> in NUnit tests. + /// + /// The type of elements in the ReadOnlyMemory. + /// The Is.EqualTo() constraint instance. + /// A constraint that can compare ReadOnlyMemory<T>. + public static EqualConstraint UsingReadOnlyMemoryComparer(this EqualConstraint constraint) + where T : IComparable + { + return constraint.Using(new ReadOnlyMemoryComparer()); + } +} + +/// +/// Comparer for ReadOnlyMemory<T>. Compares sequences by value. +/// +/// +/// The type of elements in the ReadOnlyMemory. +/// +public class ReadOnlyMemoryComparer : IComparer> + where T : IComparable +{ + /// + public int Compare(ReadOnlyMemory x, ReadOnlyMemory y) + { + // Check if sequences are equal + var xSpan = x.Span; + var ySpan = y.Span; + + // Optimized case for IEquatable implementations + if (typeof(IEquatable).IsAssignableFrom(typeof(T))) + { + var areEqual = xSpan.SequenceEqual(ySpan); + if (areEqual) + { + return 0; // Sequences are equal + } + } + else + { + // Manual equality check for non-IEquatable types + if (xSpan.Length == ySpan.Length) + { + var areEqual = true; + for (var i = 0; i < xSpan.Length; i++) + { + if (!EqualityComparer.Default.Equals(xSpan[i], ySpan[i])) + { + areEqual = false; + break; + } + } + + if (areEqual) + { + return 0; // Sequences are equal + } + } + } + + // For non-equal sequences, we need to return a consistent ordering + // First compare lengths + if (x.Length != y.Length) + return x.Length.CompareTo(y.Length); + + // Same length but different content - compare first differing element + for (var i = 0; i < x.Length; i++) + { + if (!EqualityComparer.Default.Equals(xSpan[i], ySpan[i])) + { + return xSpan[i].CompareTo(ySpan[i]); + } + } + + // Should never reach here if not equal + return 0; + } +} diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi/Core/ApiResponse.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/ApiResponse.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi/Core/ApiResponse.cs rename to seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/ApiResponse.cs diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi/Core/BaseRequest.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/BaseRequest.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi/Core/BaseRequest.cs rename to seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/BaseRequest.cs diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi/Core/CollectionItemSerializer.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/CollectionItemSerializer.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi/Core/CollectionItemSerializer.cs rename to seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/CollectionItemSerializer.cs diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi/Core/Constants.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/Constants.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi/Core/Constants.cs rename to seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/Constants.cs diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi/Core/DateOnlyConverter.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/DateOnlyConverter.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi/Core/DateOnlyConverter.cs rename to seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/DateOnlyConverter.cs diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi/Core/DateTimeSerializer.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/DateTimeSerializer.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi/Core/DateTimeSerializer.cs rename to seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/DateTimeSerializer.cs diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi/Core/EmptyRequest.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/EmptyRequest.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi/Core/EmptyRequest.cs rename to seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/EmptyRequest.cs diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi/Core/EncodingCache.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/EncodingCache.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi/Core/EncodingCache.cs rename to seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/EncodingCache.cs diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi/Core/Extensions.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/Extensions.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi/Core/Extensions.cs rename to seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/Extensions.cs diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi/Core/FormUrlEncoder.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/FormUrlEncoder.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi/Core/FormUrlEncoder.cs rename to seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/FormUrlEncoder.cs diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi/Core/HeaderValue.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/HeaderValue.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi/Core/HeaderValue.cs rename to seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/HeaderValue.cs diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi/Core/Headers.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/Headers.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi/Core/Headers.cs rename to seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/Headers.cs diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi/Core/HttpMethodExtensions.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/HttpMethodExtensions.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi/Core/HttpMethodExtensions.cs rename to seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/HttpMethodExtensions.cs diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi/Core/IIsRetryableContent.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/IIsRetryableContent.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi/Core/IIsRetryableContent.cs rename to seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/IIsRetryableContent.cs diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi/Core/IRequestOptions.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/IRequestOptions.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi/Core/IRequestOptions.cs rename to seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/IRequestOptions.cs diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi/Core/JsonAccessAttribute.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/JsonAccessAttribute.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi/Core/JsonAccessAttribute.cs rename to seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/JsonAccessAttribute.cs diff --git a/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/JsonConfiguration.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/JsonConfiguration.cs new file mode 100644 index 000000000000..df6b2c46945d --- /dev/null +++ b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/JsonConfiguration.cs @@ -0,0 +1,251 @@ +using global::System.Reflection; +using global::System.Text.Json; +using global::System.Text.Json.Nodes; +using global::System.Text.Json.Serialization; +using global::System.Text.Json.Serialization.Metadata; + +namespace SeedApi.Core; + +internal static partial class JsonOptions +{ + internal static readonly JsonSerializerOptions JsonSerializerOptions; + + static JsonOptions() + { + var options = new JsonSerializerOptions + { + Converters = + { + new DateTimeSerializer(), +#if USE_PORTABLE_DATE_ONLY + new DateOnlyConverter(), +#endif + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, +#if DEBUG + WriteIndented = true, +#endif + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = new DefaultJsonTypeInfoResolver + { + Modifiers = + { + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, + }, + }, + }; + ConfigureJsonSerializerOptions(options); + JsonSerializerOptions = options; + } + + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); +} + +internal static class JsonUtils +{ + internal static string Serialize(T obj) => + JsonSerializer.Serialize(obj, JsonOptions.JsonSerializerOptions); + + internal static JsonElement SerializeToElement(T obj) => + JsonSerializer.SerializeToElement(obj, JsonOptions.JsonSerializerOptions); + + internal static JsonDocument SerializeToDocument(T obj) => + JsonSerializer.SerializeToDocument(obj, JsonOptions.JsonSerializerOptions); + + internal static JsonNode? SerializeToNode(T obj) => + JsonSerializer.SerializeToNode(obj, JsonOptions.JsonSerializerOptions); + + internal static byte[] SerializeToUtf8Bytes(T obj) => + JsonSerializer.SerializeToUtf8Bytes(obj, JsonOptions.JsonSerializerOptions); + + internal static string SerializeWithAdditionalProperties( + T obj, + object? additionalProperties = null + ) + { + if (additionalProperties == null) + { + return Serialize(obj); + } + var additionalPropertiesJsonNode = SerializeToNode(additionalProperties); + if (additionalPropertiesJsonNode is not JsonObject additionalPropertiesJsonObject) + { + throw new InvalidOperationException( + "The additional properties must serialize to a JSON object." + ); + } + var jsonNode = SerializeToNode(obj); + if (jsonNode is not JsonObject jsonObject) + { + throw new InvalidOperationException( + "The serialized object must be a JSON object to add properties." + ); + } + MergeJsonObjects(jsonObject, additionalPropertiesJsonObject); + return jsonObject.ToJsonString(JsonOptions.JsonSerializerOptions); + } + + private static void MergeJsonObjects(JsonObject baseObject, JsonObject overrideObject) + { + foreach (var property in overrideObject) + { + if (!baseObject.TryGetPropertyValue(property.Key, out JsonNode? existingValue)) + { + baseObject[property.Key] = + property.Value != null ? JsonNode.Parse(property.Value.ToJsonString()) : null; + continue; + } + if ( + existingValue is JsonObject nestedBaseObject + && property.Value is JsonObject nestedOverrideObject + ) + { + // If both values are objects, recursively merge them. + MergeJsonObjects(nestedBaseObject, nestedOverrideObject); + continue; + } + // Otherwise, the overrideObject takes precedence. + baseObject[property.Key] = + property.Value != null ? JsonNode.Parse(property.Value.ToJsonString()) : null; + } + } + + internal static T Deserialize(string json) => + JsonSerializer.Deserialize(json, JsonOptions.JsonSerializerOptions)!; +} diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi/Core/JsonRequest.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/JsonRequest.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi/Core/JsonRequest.cs rename to seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/JsonRequest.cs diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi/Core/MultipartFormRequest.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/MultipartFormRequest.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi/Core/MultipartFormRequest.cs rename to seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/MultipartFormRequest.cs diff --git a/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/NullableAttribute.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/NullableAttribute.cs new file mode 100644 index 000000000000..8fca30535a2a --- /dev/null +++ b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedApi.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi/Core/OneOfSerializer.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/OneOfSerializer.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi/Core/OneOfSerializer.cs rename to seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/OneOfSerializer.cs diff --git a/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/Optional.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/Optional.cs new file mode 100644 index 000000000000..4a9691feca1f --- /dev/null +++ b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedApi.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/OptionalAttribute.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..b2bc05f7244f --- /dev/null +++ b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedApi.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi/Core/Public/AdditionalProperties.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/Public/AdditionalProperties.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi/Core/Public/AdditionalProperties.cs rename to seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/Public/AdditionalProperties.cs diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi/Core/Public/ClientOptions.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/Public/ClientOptions.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi/Core/Public/ClientOptions.cs rename to seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/Public/ClientOptions.cs diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi/Core/Public/FileParameter.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/Public/FileParameter.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi/Core/Public/FileParameter.cs rename to seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/Public/FileParameter.cs diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi/Core/Public/RequestOptions.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/Public/RequestOptions.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi/Core/Public/RequestOptions.cs rename to seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/Public/RequestOptions.cs diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi/Core/Public/SeedApiApiException.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/Public/SeedApiApiException.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi/Core/Public/SeedApiApiException.cs rename to seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/Public/SeedApiApiException.cs diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi/Core/Public/SeedApiException.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/Public/SeedApiException.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi/Core/Public/SeedApiException.cs rename to seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/Public/SeedApiException.cs diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi/Core/Public/Version.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/Public/Version.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi/Core/Public/Version.cs rename to seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/Public/Version.cs diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi/Core/QueryStringConverter.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/QueryStringConverter.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi/Core/QueryStringConverter.cs rename to seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/QueryStringConverter.cs diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi/Core/RawClient.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/RawClient.cs similarity index 99% rename from seed/csharp-sdk/required-nullable/src/SeedApi/Core/RawClient.cs rename to seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/RawClient.cs index 72e34bbc0e89..b60968e4eb03 100644 --- a/seed/csharp-sdk/required-nullable/src/SeedApi/Core/RawClient.cs +++ b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi/Core/StreamRequest.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/StreamRequest.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi/Core/StreamRequest.cs rename to seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/StreamRequest.cs diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi/Core/StringEnum.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/StringEnum.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi/Core/StringEnum.cs rename to seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/StringEnum.cs diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi/Core/StringEnumExtensions.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/StringEnumExtensions.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi/Core/StringEnumExtensions.cs rename to seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/StringEnumExtensions.cs diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi/Core/StringEnumSerializer.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/StringEnumSerializer.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi/Core/StringEnumSerializer.cs rename to seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/StringEnumSerializer.cs diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi/Core/ValueConvert.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/ValueConvert.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi/Core/ValueConvert.cs rename to seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Core/ValueConvert.cs diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi/ISeedApiClient.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/ISeedApiClient.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi/ISeedApiClient.cs rename to seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/ISeedApiClient.cs diff --git a/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Requests/GetFooRequest.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Requests/GetFooRequest.cs new file mode 100644 index 000000000000..5f0014ea5fd0 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Requests/GetFooRequest.cs @@ -0,0 +1,38 @@ +using System.Text.Json.Serialization; +using SeedApi.Core; + +namespace SeedApi; + +[Serializable] +public record GetFooRequest +{ + /// + /// An optional baz + /// + [JsonIgnore] + public string? OptionalBaz { get; set; } + + /// + /// An optional baz + /// + [JsonIgnore] + public Optional OptionalNullableBaz { get; set; } + + /// + /// A required baz + /// + [JsonIgnore] + public required string RequiredBaz { get; set; } + + /// + /// A required baz + /// + [JsonIgnore] + public string? RequiredNullableBaz { get; set; } + + /// + public override string ToString() + { + return JsonUtils.Serialize(this); + } +} diff --git a/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Requests/UpdateFooRequest.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Requests/UpdateFooRequest.cs new file mode 100644 index 000000000000..ee7e7eb8616d --- /dev/null +++ b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Requests/UpdateFooRequest.cs @@ -0,0 +1,38 @@ +using System.Text.Json.Serialization; +using SeedApi.Core; + +namespace SeedApi; + +[Serializable] +public record UpdateFooRequest +{ + [JsonIgnore] + public required string XIdempotencyKey { get; set; } + + /// + /// Can be explicitly set to null to clear the value + /// + [Nullable, Optional] + [JsonPropertyName("nullable_text")] + public Optional NullableText { get; set; } + + /// + /// Can be explicitly set to null to clear the value + /// + [Nullable, Optional] + [JsonPropertyName("nullable_number")] + public Optional NullableNumber { get; set; } + + /// + /// Regular non-nullable field + /// + [Optional] + [JsonPropertyName("non_nullable_text")] + public string? NonNullableText { get; set; } + + /// + public override string ToString() + { + return JsonUtils.Serialize(this); + } +} diff --git a/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/SeedApi.Custom.props b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/SeedApi.Custom.props new file mode 100644 index 000000000000..17a84cada530 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/SeedApi.Custom.props @@ -0,0 +1,20 @@ + + + + diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi/SeedApi.csproj b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/SeedApi.csproj similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi/SeedApi.csproj rename to seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/SeedApi.csproj diff --git a/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/SeedApiClient.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/SeedApiClient.cs new file mode 100644 index 000000000000..ebae001fe497 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/SeedApiClient.cs @@ -0,0 +1,152 @@ +using System.Text.Json; +using SeedApi.Core; + +namespace SeedApi; + +public partial class SeedApiClient : ISeedApiClient +{ + private readonly RawClient _client; + + public SeedApiClient(ClientOptions? clientOptions = null) + { + var defaultHeaders = new Headers( + new Dictionary() + { + { "X-Fern-Language", "C#" }, + { "X-Fern-SDK-Name", "SeedApi" }, + { "X-Fern-SDK-Version", Version.Current }, + { "User-Agent", "Fernrequired-nullable/0.0.1" }, + } + ); + clientOptions ??= new ClientOptions(); + foreach (var header in defaultHeaders) + { + if (!clientOptions.Headers.ContainsKey(header.Key)) + { + clientOptions.Headers[header.Key] = header.Value; + } + } + _client = new RawClient(clientOptions); + } + + /// + /// await client.GetFooAsync( + /// new GetFooRequest + /// { + /// RequiredBaz = "required_baz", + /// RequiredNullableBaz = "required_nullable_baz", + /// } + /// ); + /// + public async Task GetFooAsync( + GetFooRequest request, + RequestOptions? options = null, + CancellationToken cancellationToken = default + ) + { + var _query = new Dictionary(); + _query["required_baz"] = request.RequiredBaz; + _query["required_nullable_baz"] = request.RequiredNullableBaz; + if (request.OptionalBaz != null) + { + _query["optional_baz"] = request.OptionalBaz; + } + if (request.OptionalNullableBaz.IsDefined) + { + _query["optional_nullable_baz"] = request.OptionalNullableBaz.Value; + } + var response = await _client + .SendRequestAsync( + new JsonRequest + { + BaseUrl = _client.Options.BaseUrl, + Method = HttpMethod.Get, + Path = "foo", + Query = _query, + Options = options, + }, + cancellationToken + ) + .ConfigureAwait(false); + if (response.StatusCode is >= 200 and < 400) + { + var responseBody = await response.Raw.Content.ReadAsStringAsync(); + try + { + return JsonUtils.Deserialize(responseBody)!; + } + catch (JsonException e) + { + throw new SeedApiException("Failed to deserialize response", e); + } + } + + { + var responseBody = await response.Raw.Content.ReadAsStringAsync(); + throw new SeedApiApiException( + $"Error with status code {response.StatusCode}", + response.StatusCode, + responseBody + ); + } + } + + /// + /// await client.UpdateFooAsync( + /// "id", + /// new UpdateFooRequest + /// { + /// XIdempotencyKey = "X-Idempotency-Key", + /// NullableText = "nullable_text", + /// NullableNumber = 1.1, + /// NonNullableText = "non_nullable_text", + /// } + /// ); + /// + public async Task UpdateFooAsync( + string id, + UpdateFooRequest request, + RequestOptions? options = null, + CancellationToken cancellationToken = default + ) + { + var _headers = new Headers( + new Dictionary() { { "X-Idempotency-Key", request.XIdempotencyKey } } + ); + var response = await _client + .SendRequestAsync( + new JsonRequest + { + BaseUrl = _client.Options.BaseUrl, + Method = HttpMethodExtensions.Patch, + Path = string.Format("foo/{0}", ValueConvert.ToPathParameterString(id)), + Body = request, + Headers = _headers, + Options = options, + }, + cancellationToken + ) + .ConfigureAwait(false); + if (response.StatusCode is >= 200 and < 400) + { + var responseBody = await response.Raw.Content.ReadAsStringAsync(); + try + { + return JsonUtils.Deserialize(responseBody)!; + } + catch (JsonException e) + { + throw new SeedApiException("Failed to deserialize response", e); + } + } + + { + var responseBody = await response.Raw.Content.ReadAsStringAsync(); + throw new SeedApiApiException( + $"Error with status code {response.StatusCode}", + response.StatusCode, + responseBody + ); + } + } +} diff --git a/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Types/Foo.cs b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Types/Foo.cs new file mode 100644 index 000000000000..6c12fdd24c17 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/explicit-nullable-optional/src/SeedApi/Types/Foo.cs @@ -0,0 +1,40 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using SeedApi.Core; + +namespace SeedApi; + +[Serializable] +public record Foo : IJsonOnDeserialized +{ + [JsonExtensionData] + private readonly IDictionary _extensionData = + new Dictionary(); + + [Optional] + [JsonPropertyName("bar")] + public string? Bar { get; set; } + + [Nullable, Optional] + [JsonPropertyName("nullable_bar")] + public Optional NullableBar { get; set; } + + [Nullable] + [JsonPropertyName("nullable_required_bar")] + public string? NullableRequiredBar { get; set; } + + [JsonPropertyName("required_bar")] + public required string RequiredBar { get; set; } + + [JsonIgnore] + public ReadOnlyAdditionalProperties AdditionalProperties { get; private set; } = new(); + + void IJsonOnDeserialized.OnDeserialized() => + AdditionalProperties.CopyFromExtensionData(_extensionData); + + /// + public override string ToString() + { + return JsonUtils.Serialize(this); + } +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/.editorconfig b/seed/csharp-sdk/required-nullable/no-custom-config/.editorconfig new file mode 100644 index 000000000000..1e7a0adbac80 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/.editorconfig @@ -0,0 +1,35 @@ +root = true + +[*.cs] +resharper_arrange_object_creation_when_type_evident_highlighting = hint +resharper_auto_property_can_be_made_get_only_global_highlighting = hint +resharper_check_namespace_highlighting = hint +resharper_class_never_instantiated_global_highlighting = hint +resharper_class_never_instantiated_local_highlighting = hint +resharper_collection_never_updated_global_highlighting = hint +resharper_convert_type_check_pattern_to_null_check_highlighting = hint +resharper_inconsistent_naming_highlighting = hint +resharper_member_can_be_private_global_highlighting = hint +resharper_member_hides_static_from_outer_class_highlighting = hint +resharper_not_accessed_field_local_highlighting = hint +resharper_nullable_warning_suppression_is_used_highlighting = suggestion +resharper_partial_type_with_single_part_highlighting = hint +resharper_prefer_concrete_value_over_default_highlighting = none +resharper_private_field_can_be_converted_to_local_variable_highlighting = hint +resharper_property_can_be_made_init_only_global_highlighting = hint +resharper_property_can_be_made_init_only_local_highlighting = hint +resharper_redundant_name_qualifier_highlighting = none +resharper_redundant_using_directive_highlighting = hint +resharper_replace_slice_with_range_indexer_highlighting = none +resharper_unused_auto_property_accessor_global_highlighting = hint +resharper_unused_auto_property_accessor_local_highlighting = hint +resharper_unused_member_global_highlighting = hint +resharper_unused_type_global_highlighting = hint +resharper_use_string_interpolation_highlighting = hint +dotnet_diagnostic.CS1591.severity = suggestion + +[src/**/Types/*.cs] +resharper_check_namespace_highlighting = none + +[src/**/Core/Public/*.cs] +resharper_check_namespace_highlighting = none \ No newline at end of file diff --git a/seed/csharp-sdk/required-nullable/.fern/metadata.json b/seed/csharp-sdk/required-nullable/no-custom-config/.fern/metadata.json similarity index 100% rename from seed/csharp-sdk/required-nullable/.fern/metadata.json rename to seed/csharp-sdk/required-nullable/no-custom-config/.fern/metadata.json diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/.github/workflows/ci.yml b/seed/csharp-sdk/required-nullable/no-custom-config/.github/workflows/ci.yml new file mode 100644 index 000000000000..154f99b5d9c3 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/.github/workflows/ci.yml @@ -0,0 +1,97 @@ +name: ci + +on: [push] + +env: + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true + DOTNET_NOLOGO: true + +jobs: + compile: + runs-on: ubuntu-latest + + steps: + - name: Checkout repo + uses: actions/checkout@v5 + + - uses: actions/checkout@master + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.x + + - name: Print .NET info + run: dotnet --info + + - name: Install tools + run: dotnet tool restore + + - name: Restore dependencies + run: dotnet restore src/SeedApi/SeedApi.csproj + + - name: Build Release + run: dotnet build src/SeedApi/SeedApi.csproj -c Release --no-restore + + unit-tests: + runs-on: ubuntu-latest + + steps: + - name: Checkout repo + uses: actions/checkout@v5 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.x + + - name: Print .NET info + run: dotnet --info + + - name: Install tools + run: | + dotnet tool restore + + - name: Restore dependencies + run: dotnet restore src/SeedApi.Test/SeedApi.Test.csproj + + - name: Build Release + run: dotnet build src/SeedApi.Test/SeedApi.Test.csproj -c Release --no-restore + + - name: Run Tests + run: dotnet test src/SeedApi.Test/SeedApi.Test.csproj -c Release --no-build --no-restore + + + publish: + needs: [compile] + if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') + runs-on: ubuntu-latest + + steps: + - name: Checkout repo + uses: actions/checkout@v5 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.x + + - name: Print .NET info + run: dotnet --info + + - name: Install tools + run: dotnet tool restore + + - name: Restore dependencies + run: dotnet restore src/SeedApi/SeedApi.csproj + + - name: Build Release + run: dotnet build src/SeedApi/SeedApi.csproj -c Release --no-restore + + - name: Publish + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_TOKEN }} + run: | + dotnet pack src/SeedApi/SeedApi.csproj -c Release --no-build --no-restore + dotnet nuget push src/SeedApi/bin/Release/*.nupkg --api-key $NUGET_API_KEY --source "nuget.org" + diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/.gitignore b/seed/csharp-sdk/required-nullable/no-custom-config/.gitignore new file mode 100644 index 000000000000..11014f2b33d7 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/.gitignore @@ -0,0 +1,484 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +## This is based on `dotnet new gitignore` and customized by Fern + +# dotenv files +.env + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +# [Rr]elease/ (Ignored by Fern) +# [Rr]eleases/ (Ignored by Fern) +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +# [Ll]og/ (Ignored by Fern) +# [Ll]ogs/ (Ignored by Fern) + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET +project.lock.json +project.fragment.lock.json +artifacts/ + +# Tye +.tye/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml +.idea + +## +## Visual studio for Mac +## + + +# globs +Makefile.in +*.userprefs +*.usertasks +config.make +config.status +aclocal.m4 +install-sh +autom4te.cache/ +*.tar.gz +tarballs/ +test-results/ + +# Mac bundle stuff +*.dmg +*.app + +# content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# Vim temporary swap files +*.swp diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/README.md b/seed/csharp-sdk/required-nullable/no-custom-config/README.md new file mode 100644 index 000000000000..ac9b63b1eab3 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/README.md @@ -0,0 +1,113 @@ +# Seed C# Library + +[![fern shield](https://img.shields.io/badge/%F0%9F%8C%BF-Built%20with%20Fern-brightgreen)](https://buildwithfern.com?utm_source=github&utm_medium=github&utm_campaign=readme&utm_source=Seed%2FC%23) +[![nuget shield](https://img.shields.io/nuget/v/SeedApi)](https://nuget.org/packages/SeedApi) + +The Seed C# library provides convenient access to the Seed APIs from C#. + +## Table of Contents + +- [Requirements](#requirements) +- [Installation](#installation) +- [Reference](#reference) +- [Usage](#usage) +- [Exception Handling](#exception-handling) +- [Advanced](#advanced) + - [Retries](#retries) + - [Timeouts](#timeouts) +- [Contributing](#contributing) + +## Requirements + +This SDK requires: + +## Installation + +```sh +dotnet add package SeedApi +``` + +## Reference + +A full reference for this library is available [here](./reference.md). + +## Usage + +Instantiate and use the client with the following: + +```csharp +using SeedApi; + +var client = new SeedApiClient(); +await client.GetFooAsync( + new GetFooRequest + { + RequiredBaz = "required_baz", + RequiredNullableBaz = "required_nullable_baz", + } +); +``` + +## Exception Handling + +When the API returns a non-success status code (4xx or 5xx response), a subclass of the following error +will be thrown. + +```csharp +using SeedApi; + +try { + var response = await client.GetFooAsync(...); +} catch (SeedApiApiException e) { + System.Console.WriteLine(e.Body); + System.Console.WriteLine(e.StatusCode); +} +``` + +## Advanced + +### Retries + +The SDK is instrumented with automatic retries with exponential backoff. A request will be retried as long +as the request is deemed retryable and the number of retry attempts has not grown larger than the configured +retry limit (default: 2). + +A request is deemed retryable when any of the following HTTP status codes is returned: + +- [408](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) (Timeout) +- [429](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) (Too Many Requests) +- [5XX](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500) (Internal Server Errors) + +Use the `MaxRetries` request option to configure this behavior. + +```csharp +var response = await client.GetFooAsync( + ..., + new RequestOptions { + MaxRetries: 0 // Override MaxRetries at the request level + } +); +``` + +### Timeouts + +The SDK defaults to a 30 second timeout. Use the `Timeout` option to configure this behavior. + +```csharp +var response = await client.GetFooAsync( + ..., + new RequestOptions { + Timeout: TimeSpan.FromSeconds(3) // Override timeout to 3s + } +); +``` + +## Contributing + +While we value open-source contributions to this SDK, this library is generated programmatically. +Additions made directly to this library would have to be moved over to our generation code, +otherwise they would be overwritten upon the next generated release. Feel free to open a PR as +a proof of concept, but know that we will not be able to merge it as-is. We suggest opening +an issue first to discuss with us! + +On the other hand, contributions to the README are always very welcome! \ No newline at end of file diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/SeedApi.slnx b/seed/csharp-sdk/required-nullable/no-custom-config/SeedApi.slnx new file mode 100644 index 000000000000..d4c63c241aad --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/SeedApi.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/reference.md b/seed/csharp-sdk/required-nullable/no-custom-config/reference.md new file mode 100644 index 000000000000..6ca4c16b5d62 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/reference.md @@ -0,0 +1,103 @@ +# Reference +
client.GetFooAsync(GetFooRequest { ... }) -> Foo +
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```csharp +await client.GetFooAsync( + new GetFooRequest + { + RequiredBaz = "required_baz", + RequiredNullableBaz = "required_nullable_baz", + } +); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request:** `GetFooRequest` + +
+
+
+
+ + +
+
+
+ +
client.UpdateFooAsync(id, UpdateFooRequest { ... }) -> Foo +
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```csharp +await client.UpdateFooAsync( + "id", + new UpdateFooRequest + { + XIdempotencyKey = "X-Idempotency-Key", + NullableText = "nullable_text", + NullableNumber = 1.1, + NonNullableText = "non_nullable_text", + } +); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**id:** `string` + +
+
+ +
+
+ +**request:** `UpdateFooRequest` + +
+
+
+
+ + +
+
+
diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/snippet.json b/seed/csharp-sdk/required-nullable/no-custom-config/snippet.json new file mode 100644 index 000000000000..c065f84177ab --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/snippet.json @@ -0,0 +1,29 @@ +{ + "types": {}, + "endpoints": [ + { + "example_identifier": null, + "id": { + "path": "/foo", + "method": "GET", + "identifier_override": "endpoint_.getFoo" + }, + "snippet": { + "type": "csharp", + "client": "using SeedApi;\n\nvar client = new SeedApiClient();\nawait client.GetFooAsync(\n new GetFooRequest\n {\n RequiredBaz = \"required_baz\",\n RequiredNullableBaz = \"required_nullable_baz\",\n }\n);\n" + } + }, + { + "example_identifier": null, + "id": { + "path": "/foo/{id}", + "method": "PATCH", + "identifier_override": "endpoint_.updateFoo" + }, + "snippet": { + "type": "csharp", + "client": "using SeedApi;\n\nvar client = new SeedApiClient();\nawait client.UpdateFooAsync(\n \"id\",\n new UpdateFooRequest\n {\n XIdempotencyKey = \"X-Idempotency-Key\",\n NullableText = \"nullable_text\",\n NullableNumber = 1.1,\n NonNullableText = \"non_nullable_text\",\n }\n);\n" + } + } + ] +} \ No newline at end of file diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.DynamicSnippets/Example0.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.DynamicSnippets/Example0.cs new file mode 100644 index 000000000000..adbfc94a933c --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.DynamicSnippets/Example0.cs @@ -0,0 +1,22 @@ +using SeedApi; + +namespace Usage; + +public class Example0 +{ + public async Task Do() { + var client = new SeedApiClient( + clientOptions: new ClientOptions { + BaseUrl = "https://api.fern.com" + } + ); + + await client.GetFooAsync( + new GetFooRequest { + RequiredBaz = "required_baz", + RequiredNullableBaz = "required_nullable_baz" + } + ); + } + +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.DynamicSnippets/Example1.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.DynamicSnippets/Example1.cs new file mode 100644 index 000000000000..55acaca5f0be --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.DynamicSnippets/Example1.cs @@ -0,0 +1,24 @@ +using SeedApi; + +namespace Usage; + +public class Example1 +{ + public async Task Do() { + var client = new SeedApiClient( + clientOptions: new ClientOptions { + BaseUrl = "https://api.fern.com" + } + ); + + await client.GetFooAsync( + new GetFooRequest { + OptionalBaz = "optional_baz", + OptionalNullableBaz = "optional_nullable_baz", + RequiredBaz = "required_baz", + RequiredNullableBaz = "required_nullable_baz" + } + ); + } + +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.DynamicSnippets/Example2.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.DynamicSnippets/Example2.cs new file mode 100644 index 000000000000..55a58ba59bf7 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.DynamicSnippets/Example2.cs @@ -0,0 +1,25 @@ +using SeedApi; + +namespace Usage; + +public class Example2 +{ + public async Task Do() { + var client = new SeedApiClient( + clientOptions: new ClientOptions { + BaseUrl = "https://api.fern.com" + } + ); + + await client.UpdateFooAsync( + "id", + new UpdateFooRequest { + XIdempotencyKey = "X-Idempotency-Key", + NullableText = "nullable_text", + NullableNumber = 1.1, + NonNullableText = "non_nullable_text" + } + ); + } + +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.DynamicSnippets/SeedApi.DynamicSnippets.csproj b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.DynamicSnippets/SeedApi.DynamicSnippets.csproj new file mode 100644 index 000000000000..3417db2e58e2 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.DynamicSnippets/SeedApi.DynamicSnippets.csproj @@ -0,0 +1,13 @@ + + + + net8.0 + 12 + enable + enable + + + + + + \ No newline at end of file diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Core/Json/AdditionalPropertiesTests.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Core/Json/AdditionalPropertiesTests.cs new file mode 100644 index 000000000000..a12183113312 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Core/Json/AdditionalPropertiesTests.cs @@ -0,0 +1,365 @@ +using global::System.Text.Json; +using global::System.Text.Json.Serialization; +using NUnit.Framework; +using SeedApi.Core; + +namespace SeedApi.Test.Core.Json; + +[TestFixture] +public class AdditionalPropertiesTests +{ + [Test] + public void Record_OnDeserialized_ShouldPopulateAdditionalProperties() + { + // Arrange + const string json = """ + { + "id": "1", + "category": "fiction", + "title": "The Hobbit" + } + """; + + // Act + var record = JsonUtils.Deserialize(json); + + // Assert + Assert.That(record, Is.Not.Null); + Assert.Multiple(() => + { + Assert.That(record.Id, Is.EqualTo("1")); + Assert.That(record.AdditionalProperties["category"].GetString(), Is.EqualTo("fiction")); + Assert.That(record.AdditionalProperties["title"].GetString(), Is.EqualTo("The Hobbit")); + }); + } + + [Test] + public void RecordWithWriteableAdditionalProperties_OnSerialization_ShouldIncludeAdditionalProperties() + { + // Arrange + var record = new WriteableRecord + { + Id = "1", + AdditionalProperties = { ["category"] = "fiction", ["title"] = "The Hobbit" }, + }; + + // Act + var json = JsonUtils.Serialize(record); + var deserializedRecord = JsonUtils.Deserialize(json); + + // Assert + Assert.That(deserializedRecord, Is.Not.Null); + Assert.Multiple(() => + { + Assert.That(deserializedRecord.Id, Is.EqualTo("1")); + Assert.That( + deserializedRecord.AdditionalProperties["category"], + Is.InstanceOf() + ); + Assert.That( + ((JsonElement)deserializedRecord.AdditionalProperties["category"]!).GetString(), + Is.EqualTo("fiction") + ); + Assert.That( + deserializedRecord.AdditionalProperties["title"], + Is.InstanceOf() + ); + Assert.That( + ((JsonElement)deserializedRecord.AdditionalProperties["title"]!).GetString(), + Is.EqualTo("The Hobbit") + ); + }); + } + + [Test] + public void ReadOnlyAdditionalProperties_ShouldRetrieveValuesCorrectly() + { + // Arrange + var extensionData = new Dictionary + { + ["key1"] = JsonUtils.SerializeToElement("value1"), + ["key2"] = JsonUtils.SerializeToElement(123), + }; + var readOnlyProps = new ReadOnlyAdditionalProperties(); + readOnlyProps.CopyFromExtensionData(extensionData); + + // Act & Assert + Assert.That(readOnlyProps["key1"].GetString(), Is.EqualTo("value1")); + Assert.That(readOnlyProps["key2"].GetInt32(), Is.EqualTo(123)); + } + + [Test] + public void AdditionalProperties_ShouldBehaveAsDictionary() + { + // Arrange + var additionalProps = new AdditionalProperties { ["key1"] = "value1", ["key2"] = 123 }; + + // Act + additionalProps["key3"] = true; + + // Assert + Assert.Multiple(() => + { + Assert.That(additionalProps["key1"], Is.EqualTo("value1")); + Assert.That(additionalProps["key2"], Is.EqualTo(123)); + Assert.That((bool)additionalProps["key3"]!, Is.True); + Assert.That(additionalProps.Count, Is.EqualTo(3)); + }); + } + + [Test] + public void AdditionalProperties_ToJsonObject_ShouldSerializeCorrectly() + { + // Arrange + var additionalProps = new AdditionalProperties { ["key1"] = "value1", ["key2"] = 123 }; + + // Act + var jsonObject = additionalProps.ToJsonObject(); + + Assert.Multiple(() => + { + // Assert + Assert.That(jsonObject["key1"]!.GetValue(), Is.EqualTo("value1")); + Assert.That(jsonObject["key2"]!.GetValue(), Is.EqualTo(123)); + }); + } + + [Test] + public void AdditionalProperties_MixReadAndWrite_ShouldOverwriteDeserializedProperty() + { + // Arrange + const string json = """ + { + "id": "1", + "category": "fiction", + "title": "The Hobbit" + } + """; + var record = JsonUtils.Deserialize(json); + + // Act + record.AdditionalProperties["category"] = "non-fiction"; + + // Assert + Assert.Multiple(() => + { + Assert.That(record, Is.Not.Null); + Assert.That(record.Id, Is.EqualTo("1")); + Assert.That(record.AdditionalProperties["category"], Is.EqualTo("non-fiction")); + Assert.That(record.AdditionalProperties["title"], Is.InstanceOf()); + Assert.That( + ((JsonElement)record.AdditionalProperties["title"]!).GetString(), + Is.EqualTo("The Hobbit") + ); + }); + } + + [Test] + public void RecordWithReadonlyAdditionalPropertiesInts_OnDeserialized_ShouldPopulateAdditionalProperties() + { + // Arrange + const string json = """ + { + "extra1": 42, + "extra2": 99 + } + """; + + // Act + var record = JsonUtils.Deserialize(json); + + // Assert + Assert.That(record, Is.Not.Null); + Assert.Multiple(() => + { + Assert.That(record.AdditionalProperties["extra1"], Is.EqualTo(42)); + Assert.That(record.AdditionalProperties["extra2"], Is.EqualTo(99)); + }); + } + + [Test] + public void RecordWithAdditionalPropertiesInts_OnSerialization_ShouldIncludeAdditionalProperties() + { + // Arrange + var record = new WriteableRecordWithInts + { + AdditionalProperties = { ["extra1"] = 42, ["extra2"] = 99 }, + }; + + // Act + var json = JsonUtils.Serialize(record); + var deserializedRecord = JsonUtils.Deserialize(json); + + // Assert + Assert.That(deserializedRecord, Is.Not.Null); + Assert.Multiple(() => + { + Assert.That(deserializedRecord.AdditionalProperties["extra1"], Is.EqualTo(42)); + Assert.That(deserializedRecord.AdditionalProperties["extra2"], Is.EqualTo(99)); + }); + } + + [Test] + public void RecordWithReadonlyAdditionalPropertiesDictionaries_OnDeserialized_ShouldPopulateAdditionalProperties() + { + // Arrange + const string json = """ + { + "extra1": { "key1": true, "key2": false }, + "extra2": { "key3": true } + } + """; + + // Act + var record = JsonUtils.Deserialize(json); + + // Assert + Assert.That(record, Is.Not.Null); + Assert.Multiple(() => + { + Assert.That(record.AdditionalProperties["extra1"]["key1"], Is.True); + Assert.That(record.AdditionalProperties["extra1"]["key2"], Is.False); + Assert.That(record.AdditionalProperties["extra2"]["key3"], Is.True); + }); + } + + [Test] + public void RecordWithAdditionalPropertiesDictionaries_OnSerialization_ShouldIncludeAdditionalProperties() + { + // Arrange + var record = new WriteableRecordWithDictionaries + { + AdditionalProperties = + { + ["extra1"] = new Dictionary { { "key1", true }, { "key2", false } }, + ["extra2"] = new Dictionary { { "key3", true } }, + }, + }; + + // Act + var json = JsonUtils.Serialize(record); + var deserializedRecord = JsonUtils.Deserialize(json); + + // Assert + Assert.That(deserializedRecord, Is.Not.Null); + Assert.Multiple(() => + { + Assert.That(deserializedRecord.AdditionalProperties["extra1"]["key1"], Is.True); + Assert.That(deserializedRecord.AdditionalProperties["extra1"]["key2"], Is.False); + Assert.That(deserializedRecord.AdditionalProperties["extra2"]["key3"], Is.True); + }); + } + + private record Record : IJsonOnDeserialized + { + [JsonPropertyName("id")] + public required string Id { get; set; } + + [JsonExtensionData] + private readonly IDictionary _extensionData = + new Dictionary(); + + [JsonIgnore] + public ReadOnlyAdditionalProperties AdditionalProperties { get; } = new(); + + void IJsonOnDeserialized.OnDeserialized() + { + AdditionalProperties.CopyFromExtensionData(_extensionData); + } + } + + private record WriteableRecord : IJsonOnDeserialized, IJsonOnSerializing + { + [JsonPropertyName("id")] + public required string Id { get; set; } + + [JsonExtensionData] + private readonly IDictionary _extensionData = + new Dictionary(); + + [JsonIgnore] + public AdditionalProperties AdditionalProperties { get; set; } = new(); + + void IJsonOnDeserialized.OnDeserialized() + { + AdditionalProperties.CopyFromExtensionData(_extensionData); + } + + void IJsonOnSerializing.OnSerializing() + { + AdditionalProperties.CopyToExtensionData(_extensionData); + } + } + + private record RecordWithInts : IJsonOnDeserialized + { + [JsonExtensionData] + private readonly IDictionary _extensionData = + new Dictionary(); + + [JsonIgnore] + public ReadOnlyAdditionalProperties AdditionalProperties { get; } = new(); + + void IJsonOnDeserialized.OnDeserialized() + { + AdditionalProperties.CopyFromExtensionData(_extensionData); + } + } + + private record WriteableRecordWithInts : IJsonOnDeserialized, IJsonOnSerializing + { + [JsonExtensionData] + private readonly IDictionary _extensionData = + new Dictionary(); + + [JsonIgnore] + public AdditionalProperties AdditionalProperties { get; } = new(); + + void IJsonOnDeserialized.OnDeserialized() + { + AdditionalProperties.CopyFromExtensionData(_extensionData); + } + + void IJsonOnSerializing.OnSerializing() + { + AdditionalProperties.CopyToExtensionData(_extensionData); + } + } + + private record RecordWithDictionaries : IJsonOnDeserialized + { + [JsonExtensionData] + private readonly IDictionary _extensionData = + new Dictionary(); + + [JsonIgnore] + public ReadOnlyAdditionalProperties< + Dictionary + > AdditionalProperties { get; } = new(); + + void IJsonOnDeserialized.OnDeserialized() + { + AdditionalProperties.CopyFromExtensionData(_extensionData); + } + } + + private record WriteableRecordWithDictionaries : IJsonOnDeserialized, IJsonOnSerializing + { + [JsonExtensionData] + private readonly IDictionary _extensionData = + new Dictionary(); + + [JsonIgnore] + public AdditionalProperties> AdditionalProperties { get; } = new(); + + void IJsonOnDeserialized.OnDeserialized() + { + AdditionalProperties.CopyFromExtensionData(_extensionData); + } + + void IJsonOnSerializing.OnSerializing() + { + AdditionalProperties.CopyToExtensionData(_extensionData); + } + } +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Core/Json/DateOnlyJsonTests.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Core/Json/DateOnlyJsonTests.cs new file mode 100644 index 000000000000..0e8217598f51 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Core/Json/DateOnlyJsonTests.cs @@ -0,0 +1,76 @@ +using NUnit.Framework; +using SeedApi.Core; + +namespace SeedApi.Test.Core.Json; + +[TestFixture] +public class DateOnlyJsonTests +{ + [Test] + public void SerializeDateOnly_ShouldMatchExpectedFormat() + { + (DateOnly dateOnly, string expected)[] testCases = + [ + (new DateOnly(2023, 10, 5), "\"2023-10-05\""), + (new DateOnly(2023, 1, 1), "\"2023-01-01\""), + (new DateOnly(2023, 12, 31), "\"2023-12-31\""), + (new DateOnly(2023, 6, 15), "\"2023-06-15\""), + (new DateOnly(2023, 3, 10), "\"2023-03-10\""), + ]; + foreach (var (dateOnly, expected) in testCases) + { + var json = JsonUtils.Serialize(dateOnly); + Assert.That(json, Is.EqualTo(expected)); + } + } + + [Test] + public void DeserializeDateOnly_ShouldMatchExpectedDateOnly() + { + (DateOnly expected, string json)[] testCases = + [ + (new DateOnly(2023, 10, 5), "\"2023-10-05\""), + (new DateOnly(2023, 1, 1), "\"2023-01-01\""), + (new DateOnly(2023, 12, 31), "\"2023-12-31\""), + (new DateOnly(2023, 6, 15), "\"2023-06-15\""), + (new DateOnly(2023, 3, 10), "\"2023-03-10\""), + ]; + + foreach (var (expected, json) in testCases) + { + var dateOnly = JsonUtils.Deserialize(json); + Assert.That(dateOnly, Is.EqualTo(expected)); + } + } + + [Test] + public void SerializeNullableDateOnly_ShouldMatchExpectedFormat() + { + (DateOnly? dateOnly, string expected)[] testCases = + [ + (new DateOnly(2023, 10, 5), "\"2023-10-05\""), + (null, "null"), + ]; + foreach (var (dateOnly, expected) in testCases) + { + var json = JsonUtils.Serialize(dateOnly); + Assert.That(json, Is.EqualTo(expected)); + } + } + + [Test] + public void DeserializeNullableDateOnly_ShouldMatchExpectedDateOnly() + { + (DateOnly? expected, string json)[] testCases = + [ + (new DateOnly(2023, 10, 5), "\"2023-10-05\""), + (null, "null"), + ]; + + foreach (var (expected, json) in testCases) + { + var dateOnly = JsonUtils.Deserialize(json); + Assert.That(dateOnly, Is.EqualTo(expected)); + } + } +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Core/Json/DateTimeJsonTests.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Core/Json/DateTimeJsonTests.cs new file mode 100644 index 000000000000..18d6e79e2b11 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Core/Json/DateTimeJsonTests.cs @@ -0,0 +1,110 @@ +using NUnit.Framework; +using SeedApi.Core; + +namespace SeedApi.Test.Core.Json; + +[TestFixture] +public class DateTimeJsonTests +{ + [Test] + public void SerializeDateTime_ShouldMatchExpectedFormat() + { + (DateTime dateTime, string expected)[] testCases = + [ + ( + new DateTime(2023, 10, 5, 14, 30, 0, DateTimeKind.Utc), + "\"2023-10-05T14:30:00.000Z\"" + ), + (new DateTime(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc), "\"2023-01-01T00:00:00.000Z\""), + ( + new DateTime(2023, 12, 31, 23, 59, 59, DateTimeKind.Utc), + "\"2023-12-31T23:59:59.000Z\"" + ), + (new DateTime(2023, 6, 15, 12, 0, 0, DateTimeKind.Utc), "\"2023-06-15T12:00:00.000Z\""), + ( + new DateTime(2023, 3, 10, 8, 45, 30, DateTimeKind.Utc), + "\"2023-03-10T08:45:30.000Z\"" + ), + ( + new DateTime(2023, 3, 10, 8, 45, 30, 123, DateTimeKind.Utc), + "\"2023-03-10T08:45:30.123Z\"" + ), + ]; + foreach (var (dateTime, expected) in testCases) + { + var json = JsonUtils.Serialize(dateTime); + Assert.That(json, Is.EqualTo(expected)); + } + } + + [Test] + public void DeserializeDateTime_ShouldMatchExpectedDateTime() + { + (DateTime expected, string json)[] testCases = + [ + ( + new DateTime(2023, 10, 5, 14, 30, 0, DateTimeKind.Utc), + "\"2023-10-05T14:30:00.000Z\"" + ), + (new DateTime(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc), "\"2023-01-01T00:00:00.000Z\""), + ( + new DateTime(2023, 12, 31, 23, 59, 59, DateTimeKind.Utc), + "\"2023-12-31T23:59:59.000Z\"" + ), + (new DateTime(2023, 6, 15, 12, 0, 0, DateTimeKind.Utc), "\"2023-06-15T12:00:00.000Z\""), + ( + new DateTime(2023, 3, 10, 8, 45, 30, DateTimeKind.Utc), + "\"2023-03-10T08:45:30.000Z\"" + ), + (new DateTime(2023, 3, 10, 8, 45, 30, DateTimeKind.Utc), "\"2023-03-10T08:45:30Z\""), + ( + new DateTime(2023, 3, 10, 8, 45, 30, 123, DateTimeKind.Utc), + "\"2023-03-10T08:45:30.123Z\"" + ), + ]; + + foreach (var (expected, json) in testCases) + { + var dateTime = JsonUtils.Deserialize(json); + Assert.That(dateTime, Is.EqualTo(expected)); + } + } + + [Test] + public void SerializeNullableDateTime_ShouldMatchExpectedFormat() + { + (DateTime? expected, string json)[] testCases = + [ + ( + new DateTime(2023, 10, 5, 14, 30, 0, DateTimeKind.Utc), + "\"2023-10-05T14:30:00.000Z\"" + ), + (null, "null"), + ]; + + foreach (var (expected, json) in testCases) + { + var dateTime = JsonUtils.Deserialize(json); + Assert.That(dateTime, Is.EqualTo(expected)); + } + } + + [Test] + public void DeserializeNullableDateTime_ShouldMatchExpectedDateTime() + { + (DateTime? expected, string json)[] testCases = + [ + ( + new DateTime(2023, 10, 5, 14, 30, 0, DateTimeKind.Utc), + "\"2023-10-05T14:30:00.000Z\"" + ), + (null, "null"), + ]; + + foreach (var (expected, json) in testCases) + { + var dateTime = JsonUtils.Deserialize(json); + Assert.That(dateTime, Is.EqualTo(expected)); + } + } +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Core/Json/JsonAccessAttributeTests.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Core/Json/JsonAccessAttributeTests.cs new file mode 100644 index 000000000000..969acd620998 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Core/Json/JsonAccessAttributeTests.cs @@ -0,0 +1,160 @@ +using global::System.Text.Json.Serialization; +using NUnit.Framework; +using SeedApi.Core; + +namespace SeedApi.Test.Core.Json; + +[TestFixture] +public class JsonAccessAttributeTests +{ + private class MyClass + { + [JsonPropertyName("read_only_prop")] + [JsonAccess(JsonAccessType.ReadOnly)] + public string? ReadOnlyProp { get; set; } + + [JsonPropertyName("write_only_prop")] + [JsonAccess(JsonAccessType.WriteOnly)] + public string? WriteOnlyProp { get; set; } + + [JsonPropertyName("normal_prop")] + public string? NormalProp { get; set; } + + [JsonPropertyName("read_only_nullable_list")] + [JsonAccess(JsonAccessType.ReadOnly)] + public IEnumerable? ReadOnlyNullableList { get; set; } + + [JsonPropertyName("read_only_list")] + [JsonAccess(JsonAccessType.ReadOnly)] + public IEnumerable ReadOnlyList { get; set; } = []; + + [JsonPropertyName("write_only_nullable_list")] + [JsonAccess(JsonAccessType.WriteOnly)] + public IEnumerable? WriteOnlyNullableList { get; set; } + + [JsonPropertyName("write_only_list")] + [JsonAccess(JsonAccessType.WriteOnly)] + public IEnumerable WriteOnlyList { get; set; } = []; + + [JsonPropertyName("normal_list")] + public IEnumerable NormalList { get; set; } = []; + + [JsonPropertyName("normal_nullable_list")] + public IEnumerable? NullableNormalList { get; set; } + } + + [Test] + public void JsonAccessAttribute_ShouldWorkAsExpected() + { + const string json = """ + { + "read_only_prop": "read", + "write_only_prop": "write", + "normal_prop": "normal_prop", + "read_only_nullable_list": ["item1", "item2"], + "read_only_list": ["item3", "item4"], + "write_only_nullable_list": ["item5", "item6"], + "write_only_list": ["item7", "item8"], + "normal_list": ["normal1", "normal2"], + "normal_nullable_list": ["normal1", "normal2"] + } + """; + var obj = JsonUtils.Deserialize(json); + + Assert.Multiple(() => + { + // String properties + Assert.That(obj.ReadOnlyProp, Is.EqualTo("read")); + Assert.That(obj.WriteOnlyProp, Is.Null); + Assert.That(obj.NormalProp, Is.EqualTo("normal_prop")); + + // List properties - read only + var nullableReadOnlyList = obj.ReadOnlyNullableList?.ToArray(); + Assert.That(nullableReadOnlyList, Is.Not.Null); + Assert.That(nullableReadOnlyList, Has.Length.EqualTo(2)); + Assert.That(nullableReadOnlyList![0], Is.EqualTo("item1")); + Assert.That(nullableReadOnlyList![1], Is.EqualTo("item2")); + + var readOnlyList = obj.ReadOnlyList.ToArray(); + Assert.That(readOnlyList, Is.Not.Null); + Assert.That(readOnlyList, Has.Length.EqualTo(2)); + Assert.That(readOnlyList[0], Is.EqualTo("item3")); + Assert.That(readOnlyList[1], Is.EqualTo("item4")); + + // List properties - write only + Assert.That(obj.WriteOnlyNullableList, Is.Null); + Assert.That(obj.WriteOnlyList, Is.Not.Null); + Assert.That(obj.WriteOnlyList, Is.Empty); + + // Normal list property + var normalList = obj.NormalList.ToArray(); + Assert.That(normalList, Is.Not.Null); + Assert.That(normalList, Has.Length.EqualTo(2)); + Assert.That(normalList[0], Is.EqualTo("normal1")); + Assert.That(normalList[1], Is.EqualTo("normal2")); + }); + + // Set up values for serialization + obj.WriteOnlyProp = "write"; + obj.NormalProp = "new_value"; + obj.WriteOnlyNullableList = new List { "write1", "write2" }; + obj.WriteOnlyList = new List { "write3", "write4" }; + obj.NormalList = new List { "new_normal" }; + obj.NullableNormalList = new List { "new_normal" }; + + var serializedJson = JsonUtils.Serialize(obj); + const string expectedJson = """ + { + "write_only_prop": "write", + "normal_prop": "new_value", + "write_only_nullable_list": [ + "write1", + "write2" + ], + "write_only_list": [ + "write3", + "write4" + ], + "normal_list": [ + "new_normal" + ], + "normal_nullable_list": [ + "new_normal" + ] + } + """; + Assert.That(serializedJson, Is.EqualTo(expectedJson).IgnoreWhiteSpace); + } + + [Test] + public void JsonAccessAttribute_WithNullListsInJson_ShouldWorkAsExpected() + { + const string json = """ + { + "read_only_prop": "read", + "normal_prop": "normal_prop", + "read_only_nullable_list": null, + "read_only_list": [] + } + """; + var obj = JsonUtils.Deserialize(json); + + Assert.Multiple(() => + { + // Read-only nullable list should be null when JSON contains null + var nullableReadOnlyList = obj.ReadOnlyNullableList?.ToArray(); + Assert.That(nullableReadOnlyList, Is.Null); + + // Read-only non-nullable list should never be null, but empty when JSON contains null + var readOnlyList = obj.ReadOnlyList.ToArray(); // This should be initialized to an empty list by default + Assert.That(readOnlyList, Is.Not.Null); + Assert.That(readOnlyList, Is.Empty); + }); + + // Serialize and verify read-only lists are not included + var serializedJson = JsonUtils.Serialize(obj); + Assert.That(serializedJson, Does.Not.Contain("read_only_prop")); + Assert.That(serializedJson, Does.Not.Contain("read_only_nullable_list")); + Assert.That(serializedJson, Does.Not.Contain("read_only_list")); + } +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Core/Json/OneOfSerializerTests.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Core/Json/OneOfSerializerTests.cs new file mode 100644 index 000000000000..8936cc4a4403 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Core/Json/OneOfSerializerTests.cs @@ -0,0 +1,314 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using NUnit.Framework; +using OneOf; +using SeedApi.Core; + +namespace SeedApi.Test.Core.Json; + +[TestFixture] +[Parallelizable(ParallelScope.All)] +public class OneOfSerializerTests +{ + private class Foo + { + [JsonPropertyName("string_prop")] + public required string StringProp { get; set; } + } + + private class Bar + { + [JsonPropertyName("int_prop")] + public required int IntProp { get; set; } + } + + private static readonly OneOf OneOf1 = OneOf< + string, + int, + object, + Foo, + Bar + >.FromT2(new { }); + private const string OneOf1String = "{}"; + + private static readonly OneOf OneOf2 = OneOf< + string, + int, + object, + Foo, + Bar + >.FromT0("test"); + private const string OneOf2String = "\"test\""; + + private static readonly OneOf OneOf3 = OneOf< + string, + int, + object, + Foo, + Bar + >.FromT1(123); + private const string OneOf3String = "123"; + + private static readonly OneOf OneOf4 = OneOf< + string, + int, + object, + Foo, + Bar + >.FromT3(new Foo { StringProp = "test" }); + private const string OneOf4String = "{\"string_prop\": \"test\"}"; + + private static readonly OneOf OneOf5 = OneOf< + string, + int, + object, + Foo, + Bar + >.FromT4(new Bar { IntProp = 5 }); + private const string OneOf5String = "{\"int_prop\": 5}"; + + [Test] + public void Serialize_OneOfs_Should_Return_Expected_String() + { + (OneOf, string)[] testData = + [ + (OneOf1, OneOf1String), + (OneOf2, OneOf2String), + (OneOf3, OneOf3String), + (OneOf4, OneOf4String), + (OneOf5, OneOf5String), + ]; + Assert.Multiple(() => + { + foreach (var (oneOf, expected) in testData) + { + var result = JsonUtils.Serialize(oneOf); + Assert.That(result, Is.EqualTo(expected).IgnoreWhiteSpace); + } + }); + } + + [Test] + public void OneOfs_Should_Deserialize_From_String() + { + (OneOf, string)[] testData = + [ + (OneOf1, OneOf1String), + (OneOf2, OneOf2String), + (OneOf3, OneOf3String), + (OneOf4, OneOf4String), + (OneOf5, OneOf5String), + ]; + Assert.Multiple(() => + { + foreach (var (oneOf, json) in testData) + { + var result = JsonUtils.Deserialize>(json); + Assert.That(result.Index, Is.EqualTo(oneOf.Index)); + Assert.That(json, Is.EqualTo(JsonUtils.Serialize(result.Value)).IgnoreWhiteSpace); + } + }); + } + + private static readonly OneOf? NullableOneOf1 = null; + private const string NullableOneOf1String = "null"; + + private static readonly OneOf? NullableOneOf2 = OneOf< + string, + int, + object, + Foo, + Bar + >.FromT4(new Bar { IntProp = 5 }); + private const string NullableOneOf2String = "{\"int_prop\": 5}"; + + [Test] + public void Serialize_NullableOneOfs_Should_Return_Expected_String() + { + (OneOf?, string)[] testData = + [ + (NullableOneOf1, NullableOneOf1String), + (NullableOneOf2, NullableOneOf2String), + ]; + Assert.Multiple(() => + { + foreach (var (oneOf, expected) in testData) + { + var result = JsonUtils.Serialize(oneOf); + Assert.That(result, Is.EqualTo(expected).IgnoreWhiteSpace); + } + }); + } + + [Test] + public void NullableOneOfs_Should_Deserialize_From_String() + { + (OneOf?, string)[] testData = + [ + (NullableOneOf1, NullableOneOf1String), + (NullableOneOf2, NullableOneOf2String), + ]; + Assert.Multiple(() => + { + foreach (var (oneOf, json) in testData) + { + var result = JsonUtils.Deserialize?>(json); + Assert.That(result?.Index, Is.EqualTo(oneOf?.Index)); + Assert.That(json, Is.EqualTo(JsonUtils.Serialize(result?.Value)).IgnoreWhiteSpace); + } + }); + } + + private static readonly OneOf OneOfWithNullable1 = OneOf< + string, + int, + Foo? + >.FromT2(null); + private const string OneOfWithNullable1String = "null"; + + private static readonly OneOf OneOfWithNullable2 = OneOf< + string, + int, + Foo? + >.FromT2(new Foo { StringProp = "test" }); + private const string OneOfWithNullable2String = "{\"string_prop\": \"test\"}"; + + private static readonly OneOf OneOfWithNullable3 = OneOf< + string, + int, + Foo? + >.FromT0("test"); + private const string OneOfWithNullable3String = "\"test\""; + + [Test] + public void Serialize_OneOfWithNullables_Should_Return_Expected_String() + { + (OneOf, string)[] testData = + [ + (OneOfWithNullable1, OneOfWithNullable1String), + (OneOfWithNullable2, OneOfWithNullable2String), + (OneOfWithNullable3, OneOfWithNullable3String), + ]; + Assert.Multiple(() => + { + foreach (var (oneOf, expected) in testData) + { + var result = JsonUtils.Serialize(oneOf); + Assert.That(result, Is.EqualTo(expected).IgnoreWhiteSpace); + } + }); + } + + [Test] + public void OneOfWithNullables_Should_Deserialize_From_String() + { + (OneOf, string)[] testData = + [ + // (OneOfWithNullable1, OneOfWithNullable1String), // not possible with .NET's JSON serializer + (OneOfWithNullable2, OneOfWithNullable2String), + (OneOfWithNullable3, OneOfWithNullable3String), + ]; + Assert.Multiple(() => + { + foreach (var (oneOf, json) in testData) + { + var result = JsonUtils.Deserialize>(json); + Assert.That(result.Index, Is.EqualTo(oneOf.Index)); + Assert.That(json, Is.EqualTo(JsonUtils.Serialize(result.Value)).IgnoreWhiteSpace); + } + }); + } + + [Test] + public void Serialize_OneOfWithObjectLast_Should_Return_Expected_String() + { + var oneOfWithObjectLast = OneOf.FromT4( + new { random = "data" } + ); + const string oneOfWithObjectLastString = "{\"random\": \"data\"}"; + + var result = JsonUtils.Serialize(oneOfWithObjectLast); + Assert.That(result, Is.EqualTo(oneOfWithObjectLastString).IgnoreWhiteSpace); + } + + [Test] + public void OneOfWithObjectLast_Should_Deserialize_From_String() + { + const string oneOfWithObjectLastString = "{\"random\": \"data\"}"; + var result = JsonUtils.Deserialize>( + oneOfWithObjectLastString + ); + Assert.Multiple(() => + { + Assert.That(result.Index, Is.EqualTo(4)); + Assert.That(result.Value, Is.InstanceOf()); + Assert.That( + JsonUtils.Serialize(result.Value), + Is.EqualTo(oneOfWithObjectLastString).IgnoreWhiteSpace + ); + }); + } + + [Test] + public void Serialize_OneOfWithObjectNotLast_Should_Return_Expected_String() + { + var oneOfWithObjectNotLast = OneOf.FromT1( + new { random = "data" } + ); + const string oneOfWithObjectNotLastString = "{\"random\": \"data\"}"; + + var result = JsonUtils.Serialize(oneOfWithObjectNotLast); + Assert.That(result, Is.EqualTo(oneOfWithObjectNotLastString).IgnoreWhiteSpace); + } + + [Test] + public void OneOfWithObjectNotLast_Should_Deserialize_From_String() + { + const string oneOfWithObjectNotLastString = "{\"random\": \"data\"}"; + var result = JsonUtils.Deserialize>( + oneOfWithObjectNotLastString + ); + Assert.Multiple(() => + { + Assert.That(result.Index, Is.EqualTo(1)); + Assert.That(result.Value, Is.InstanceOf()); + Assert.That( + JsonUtils.Serialize(result.Value), + Is.EqualTo(oneOfWithObjectNotLastString).IgnoreWhiteSpace + ); + }); + } + + [Test] + public void Serialize_OneOfSingleType_Should_Return_Expected_String() + { + var oneOfSingle = OneOf.FromT0("single"); + const string oneOfSingleString = "\"single\""; + + var result = JsonUtils.Serialize(oneOfSingle); + Assert.That(result, Is.EqualTo(oneOfSingleString)); + } + + [Test] + public void OneOfSingleType_Should_Deserialize_From_String() + { + const string oneOfSingleString = "\"single\""; + var result = JsonUtils.Deserialize>(oneOfSingleString); + Assert.Multiple(() => + { + Assert.That(result.Index, Is.EqualTo(0)); + Assert.That(result.Value, Is.EqualTo("single")); + }); + } + + [Test] + public void Deserialize_InvalidData_Should_Throw_Exception() + { + const string invalidJson = "{\"invalid\": \"data\"}"; + + Assert.Throws(() => + { + JsonUtils.Deserialize>(invalidJson); + }); + } +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Core/Json/StringEnumSerializerTests.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Core/Json/StringEnumSerializerTests.cs new file mode 100644 index 000000000000..80c67f7a75cb --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Core/Json/StringEnumSerializerTests.cs @@ -0,0 +1,138 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using NUnit.Framework; +using SeedApi.Core; + +namespace SeedApi.Test.Core.Json; + +[TestFixture] +[Parallelizable(ParallelScope.All)] +public class StringEnumSerializerTests +{ + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + + private static readonly DummyEnum KnownEnumValue2 = DummyEnum.KnownValue2; + private static readonly DummyEnum UnknownEnumValue = DummyEnum.FromCustom("unknown_value"); + + private static readonly string JsonWithKnownEnum2 = $$""" + { + "enum_property": "{{KnownEnumValue2}}" + } + """; + + private static readonly string JsonWithUnknownEnum = $$""" + { + "enum_property": "{{UnknownEnumValue}}" + } + """; + + [Test] + public void ShouldParseKnownEnumValue2() + { + var obj = JsonSerializer.Deserialize(JsonWithKnownEnum2, JsonOptions); + Assert.That(obj, Is.Not.Null); + Assert.That(obj.EnumProperty, Is.EqualTo(KnownEnumValue2)); + } + + [Test] + public void ShouldParseUnknownEnum() + { + var obj = JsonSerializer.Deserialize(JsonWithUnknownEnum, JsonOptions); + Assert.That(obj, Is.Not.Null); + Assert.That(obj.EnumProperty, Is.EqualTo(UnknownEnumValue)); + } + + [Test] + public void ShouldSerializeKnownEnumValue2() + { + var json = JsonSerializer.SerializeToElement( + new DummyObject { EnumProperty = KnownEnumValue2 }, + JsonOptions + ); + TestContext.Out.WriteLine("Serialized JSON: \n" + json); + var enumString = json.GetProperty("enum_property").GetString(); + Assert.That(enumString, Is.Not.Null); + Assert.That(enumString, Is.EqualTo(KnownEnumValue2)); + } + + [Test] + public void ShouldSerializeUnknownEnum() + { + var json = JsonSerializer.SerializeToElement( + new DummyObject { EnumProperty = UnknownEnumValue }, + JsonOptions + ); + TestContext.Out.WriteLine("Serialized JSON: \n" + json); + var enumString = json.GetProperty("enum_property").GetString(); + Assert.That(enumString, Is.Not.Null); + Assert.That(enumString, Is.EqualTo(UnknownEnumValue)); + } +} + +public class DummyObject +{ + [JsonPropertyName("enum_property")] + public DummyEnum EnumProperty { get; set; } +} + +[JsonConverter(typeof(StringEnumSerializer))] +public readonly record struct DummyEnum : IStringEnum +{ + public DummyEnum(string value) + { + Value = value; + } + + /// + /// The string value of the enum. + /// + public string Value { get; } + + public static readonly DummyEnum KnownValue1 = FromCustom(Values.KnownValue1); + + public static readonly DummyEnum KnownValue2 = FromCustom(Values.KnownValue2); + + /// + /// Constant strings for enum values + /// + public static class Values + { + public const string KnownValue1 = "known_value1"; + + public const string KnownValue2 = "known_value2"; + } + + /// + /// Create a string enum with the given value. + /// + public static DummyEnum FromCustom(string value) + { + return new DummyEnum(value); + } + + /// + /// Returns the string value of the enum. + /// + public override string ToString() + { + return Value; + } + + public bool Equals(string? other) + { + return Value.Equals(other); + } + + public override int GetHashCode() + { + return Value.GetHashCode(); + } + + public static explicit operator string(DummyEnum value) => value.Value; + + public static explicit operator DummyEnum(string value) => new(value); + + public static bool operator ==(DummyEnum value1, string value2) => value1.Value.Equals(value2); + + public static bool operator !=(DummyEnum value1, string value2) => !value1.Value.Equals(value2); +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Core/QueryStringConverterTests.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Core/QueryStringConverterTests.cs new file mode 100644 index 000000000000..32002e9380bc --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Core/QueryStringConverterTests.cs @@ -0,0 +1,124 @@ +using NUnit.Framework; +using SeedApi.Core; + +namespace SeedApi.Test.Core; + +[TestFixture] +public class QueryStringConverterTests +{ + [Test] + public void ToQueryStringCollection_Form() + { + var obj = new + { + Name = "John", + Age = 30, + Address = new + { + Street = "123 Main St", + City = "Anytown", + Coordinates = new[] { 39.781721f, -89.650148f }, + }, + Tags = new[] { "Developer", "Blogger" }, + }; + var result = QueryStringConverter.ToForm(obj); + var expected = new List> + { + new("Name", "John"), + new("Age", "30"), + new("Address[Street]", "123 Main St"), + new("Address[City]", "Anytown"), + new("Address[Coordinates]", "39.78172,-89.65015"), + new("Tags", "Developer,Blogger"), + }; + Assert.That(result, Is.EqualTo(expected)); + } + + [Test] + public void ToQueryStringCollection_ExplodedForm() + { + var obj = new + { + Name = "John", + Age = 30, + Address = new + { + Street = "123 Main St", + City = "Anytown", + Coordinates = new[] { 39.781721f, -89.650148f }, + }, + Tags = new[] { "Developer", "Blogger" }, + }; + var result = QueryStringConverter.ToExplodedForm(obj); + var expected = new List> + { + new("Name", "John"), + new("Age", "30"), + new("Address[Street]", "123 Main St"), + new("Address[City]", "Anytown"), + new("Address[Coordinates]", "39.78172"), + new("Address[Coordinates]", "-89.65015"), + new("Tags", "Developer"), + new("Tags", "Blogger"), + }; + Assert.That(result, Is.EqualTo(expected)); + } + + [Test] + public void ToQueryStringCollection_DeepObject() + { + var obj = new + { + Name = "John", + Age = 30, + Address = new + { + Street = "123 Main St", + City = "Anytown", + Coordinates = new[] { 39.781721f, -89.650148f }, + }, + Tags = new[] { "Developer", "Blogger" }, + }; + var result = QueryStringConverter.ToDeepObject(obj); + var expected = new List> + { + new("Name", "John"), + new("Age", "30"), + new("Address[Street]", "123 Main St"), + new("Address[City]", "Anytown"), + new("Address[Coordinates][0]", "39.78172"), + new("Address[Coordinates][1]", "-89.65015"), + new("Tags[0]", "Developer"), + new("Tags[1]", "Blogger"), + }; + Assert.That(result, Is.EqualTo(expected)); + } + + [Test] + public void ToQueryStringCollection_OnString_ThrowsException() + { + var exception = Assert.Throws(() => + QueryStringConverter.ToForm("invalid") + ); + Assert.That( + exception.Message, + Is.EqualTo( + "Only objects can be converted to query string collections. Given type is String." + ) + ); + } + + [Test] + public void ToQueryStringCollection_OnArray_ThrowsException() + { + var exception = Assert.Throws(() => + QueryStringConverter.ToForm(Array.Empty()) + ); + Assert.That( + exception.Message, + Is.EqualTo( + "Only objects can be converted to query string collections. Given type is Array." + ) + ); + } +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Core/RawClientTests/AdditionalHeadersTests.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Core/RawClientTests/AdditionalHeadersTests.cs new file mode 100644 index 000000000000..1aebcb6b56d4 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Core/RawClientTests/AdditionalHeadersTests.cs @@ -0,0 +1,138 @@ +using NUnit.Framework; +using SeedApi.Core; +using WireMock.Server; +using SystemTask = global::System.Threading.Tasks.Task; +using WireMockRequest = WireMock.RequestBuilders.Request; +using WireMockResponse = WireMock.ResponseBuilders.Response; + +// ReSharper disable NullableWarningSuppressionIsUsed + +namespace SeedApi.Test.Core.RawClientTests; + +[TestFixture] +[Parallelizable(ParallelScope.Self)] +public class AdditionalHeadersTests +{ + private WireMockServer _server; + private HttpClient _httpClient; + private RawClient _rawClient; + private string _baseUrl; + + [SetUp] + public void SetUp() + { + _server = WireMockServer.Start(); + _baseUrl = _server.Url ?? ""; + _httpClient = new HttpClient { BaseAddress = new Uri(_baseUrl) }; + _rawClient = new RawClient( + new ClientOptions + { + HttpClient = _httpClient, + Headers = new Headers( + new Dictionary + { + ["a"] = "client_headers", + ["b"] = "client_headers", + ["c"] = "client_headers", + ["d"] = "client_headers", + ["e"] = "client_headers", + ["f"] = "client_headers", + ["client_multiple"] = "client_headers", + } + ), + AdditionalHeaders = new List> + { + new("b", "client_additional_headers"), + new("c", "client_additional_headers"), + new("d", "client_additional_headers"), + new("e", null), + new("client_multiple", "client_additional_headers1"), + new("client_multiple", "client_additional_headers2"), + }, + } + ); + } + + [Test] + public async SystemTask SendRequestAsync_AdditionalHeaderParameters() + { + _server + .Given(WireMockRequest.Create().WithPath("/test").UsingGet()) + .RespondWith(WireMockResponse.Create().WithStatusCode(200).WithBody("Success")); + + var request = new JsonRequest + { + BaseUrl = _baseUrl, + Method = HttpMethod.Get, + Path = "/test", + Headers = new Headers( + new Dictionary + { + ["c"] = "request_headers", + ["d"] = "request_headers", + ["request_multiple"] = "request_headers", + } + ), + Options = new RequestOptions + { + AdditionalHeaders = new List> + { + new("d", "request_additional_headers"), + new("f", null), + new("request_multiple", "request_additional_headers1"), + new("request_multiple", "request_additional_headers2"), + }, + }, + }; + + var response = await _rawClient.SendRequestAsync(request); + Assert.That(response.StatusCode, Is.EqualTo(200)); + + var content = await response.Raw.Content.ReadAsStringAsync(); + Assert.Multiple(() => + { + Assert.That(content, Is.EqualTo("Success")); + Assert.That(_server.LogEntries.Count, Is.EqualTo(1)); + var headers = + _server.LogEntries[0].RequestMessage.Headers ?? throw new global::System.Exception( + "Headers are null" + ); + + Assert.That(headers, Contains.Key("client_multiple")); + Assert.That(headers!["client_multiple"][0], Does.Contain("client_additional_headers1")); + Assert.That(headers["client_multiple"][0], Does.Contain("client_additional_headers2")); + + Assert.That(headers, Contains.Key("request_multiple")); + Assert.That( + headers["request_multiple"][0], + Does.Contain("request_additional_headers1") + ); + Assert.That( + headers["request_multiple"][0], + Does.Contain("request_additional_headers2") + ); + + Assert.That(headers, Contains.Key("a")); + Assert.That(headers["a"][0], Does.Contain("client_headers")); + + Assert.That(headers, Contains.Key("b")); + Assert.That(headers["b"][0], Does.Contain("client_additional_headers")); + + Assert.That(headers, Contains.Key("c")); + Assert.That(headers["c"][0], Does.Contain("request_headers")); + + Assert.That(headers, Contains.Key("d")); + Assert.That(headers["d"][0], Does.Contain("request_additional_headers")); + + Assert.That(headers, Does.Not.ContainKey("e")); + Assert.That(headers, Does.Not.ContainKey("f")); + }); + } + + [TearDown] + public void TearDown() + { + _server.Dispose(); + _httpClient.Dispose(); + } +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Core/RawClientTests/AdditionalParametersTests.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Core/RawClientTests/AdditionalParametersTests.cs new file mode 100644 index 000000000000..338b3701e504 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Core/RawClientTests/AdditionalParametersTests.cs @@ -0,0 +1,300 @@ +using NUnit.Framework; +using SeedApi.Core; +using WireMock.Matchers; +using WireMock.Server; +using SystemTask = global::System.Threading.Tasks.Task; +using WireMockRequest = WireMock.RequestBuilders.Request; +using WireMockResponse = WireMock.ResponseBuilders.Response; + +namespace SeedApi.Test.Core.RawClientTests; + +[TestFixture] +[Parallelizable(ParallelScope.Self)] +public class AdditionalParametersTests +{ + private WireMockServer _server; + private HttpClient _httpClient; + private RawClient _rawClient; + private string _baseUrl; + + [SetUp] + public void SetUp() + { + _server = WireMockServer.Start(); + _baseUrl = _server.Url ?? ""; + _httpClient = new HttpClient { BaseAddress = new Uri(_baseUrl) }; + _rawClient = new RawClient(new ClientOptions { HttpClient = _httpClient }); + } + + [Test] + public async SystemTask SendRequestAsync_AdditionalQueryParameters() + { + _server + .Given(WireMockRequest.Create().WithPath("/test").WithParam("foo", "bar").UsingGet()) + .RespondWith(WireMockResponse.Create().WithStatusCode(200).WithBody("Success")); + + var request = new JsonRequest() + { + BaseUrl = _baseUrl, + Method = HttpMethod.Get, + Path = "/test", + Options = new RequestOptions + { + AdditionalQueryParameters = new List> + { + new("foo", "bar"), + }, + }, + }; + + var response = await _rawClient.SendRequestAsync(request); + Assert.That(response.StatusCode, Is.EqualTo(200)); + + var content = await response.Raw.Content.ReadAsStringAsync(); + Assert.That(content, Is.EqualTo("Success")); + + Assert.That(_server.LogEntries.Count, Is.EqualTo(1)); + } + + [Test] + public async SystemTask SendRequestAsync_AdditionalQueryParameters_Override() + { + _server + .Given(WireMockRequest.Create().WithPath("/test").WithParam("foo", "null").UsingGet()) + .RespondWith(WireMockResponse.Create().WithStatusCode(200).WithBody("Success")); + + var request = new JsonRequest() + { + BaseUrl = _baseUrl, + Method = HttpMethod.Get, + Path = "/test", + Query = new Dictionary { { "foo", "bar" } }, + Options = new RequestOptions + { + AdditionalQueryParameters = new List> + { + new("foo", "null"), + }, + }, + }; + + var response = await _rawClient.SendRequestAsync(request); + Assert.That(response.StatusCode, Is.EqualTo(200)); + + var content = await response.Raw.Content.ReadAsStringAsync(); + Assert.That(content, Is.EqualTo("Success")); + + Assert.That(_server.LogEntries.Count, Is.EqualTo(1)); + } + + [Test] + public async SystemTask SendRequestAsync_AdditionalQueryParameters_Merge() + { + _server + .Given(WireMockRequest.Create().WithPath("/test").UsingGet()) + .RespondWith(WireMockResponse.Create().WithStatusCode(200).WithBody("Success")); + + var request = new JsonRequest() + { + BaseUrl = _baseUrl, + Method = HttpMethod.Get, + Path = "/test", + Query = new Dictionary { { "foo", "baz" } }, + Options = new RequestOptions + { + AdditionalQueryParameters = new List> + { + new("foo", "one"), + new("foo", "two"), + }, + }, + }; + + var response = await _rawClient.SendRequestAsync(request); + Assert.That(response.StatusCode, Is.EqualTo(200)); + + var content = await response.Raw.Content.ReadAsStringAsync(); + Assert.That(content, Is.EqualTo("Success")); + + Assert.That(_server.LogEntries.Count, Is.EqualTo(1)); + + var requestUrl = _server.LogEntries.First().RequestMessage.Url; + Assert.That(requestUrl, Does.Contain("foo=one")); + Assert.That(requestUrl, Does.Contain("foo=two")); + Assert.That(requestUrl, Does.Not.Contain("foo=baz")); + } + + [Test] + public async SystemTask SendRequestAsync_AdditionalBodyProperties() + { + string expectedBody = "{\n \"foo\": \"bar\",\n \"baz\": \"qux\"\n}"; + _server + .Given( + WireMockRequest + .Create() + .WithPath("/test") + .UsingPost() + .WithBody(new JsonMatcher(expectedBody)) + ) + .RespondWith(WireMockResponse.Create().WithStatusCode(200).WithBody("Success")); + + var request = new JsonRequest + { + BaseUrl = _baseUrl, + Method = HttpMethod.Post, + Path = "/test", + Body = new Dictionary { { "foo", "bar" } }, + Options = new RequestOptions + { + AdditionalBodyProperties = new Dictionary { { "baz", "qux" } }, + }, + }; + + var response = await _rawClient.SendRequestAsync(request); + Assert.That(response.StatusCode, Is.EqualTo(200)); + + var content = await response.Raw.Content.ReadAsStringAsync(); + Assert.That(content, Is.EqualTo("Success")); + + Assert.That(_server.LogEntries.Count, Is.EqualTo(1)); + } + + [Test] + public async SystemTask SendRequestAsync_AdditionalBodyProperties_Override() + { + string expectedBody = "{\n \"foo\": null\n}"; + _server + .Given( + WireMockRequest + .Create() + .WithPath("/test") + .UsingPost() + .WithBody(new JsonMatcher(expectedBody)) + ) + .RespondWith(WireMockResponse.Create().WithStatusCode(200).WithBody("Success")); + + var request = new JsonRequest + { + BaseUrl = _baseUrl, + Method = HttpMethod.Post, + Path = "/test", + Body = new Dictionary { { "foo", "bar" } }, + Options = new RequestOptions + { + AdditionalBodyProperties = new Dictionary { { "foo", null } }, + }, + }; + + var response = await _rawClient.SendRequestAsync(request); + Assert.That(response.StatusCode, Is.EqualTo(200)); + + var content = await response.Raw.Content.ReadAsStringAsync(); + Assert.That(content, Is.EqualTo("Success")); + + Assert.That(_server.LogEntries.Count, Is.EqualTo(1)); + } + + [Test] + public async SystemTask SendRequestAsync_AdditionalBodyProperties_DeepMerge() + { + const string expectedBody = """ + { + "foo": { + "inner1": "original", + "inner2": "overridden", + "inner3": { + "deepProp1": "deep-override", + "deepProp2": "original", + "deepProp3": null, + "deepProp4": "new-value" + } + }, + "bar": "new-value", + "baz": ["new","value"] + } + """; + + _server + .Given( + WireMockRequest + .Create() + .WithPath("/test-deep-merge") + .UsingPost() + .WithBody(new JsonMatcher(expectedBody)) + ) + .RespondWith(WireMockResponse.Create().WithStatusCode(200).WithBody("Success")); + + var request = new JsonRequest + { + BaseUrl = _baseUrl, + Method = HttpMethod.Post, + Path = "/test-deep-merge", + Body = new Dictionary + { + { + "foo", + new Dictionary + { + { "inner1", "original" }, + { "inner2", "original" }, + { + "inner3", + new Dictionary + { + { "deepProp1", "deep-original" }, + { "deepProp2", "original" }, + { "deepProp3", "" }, + } + }, + } + }, + { + "baz", + new List { "original" } + }, + }, + Options = new RequestOptions + { + AdditionalBodyProperties = new Dictionary + { + { + "foo", + new Dictionary + { + { "inner2", "overridden" }, + { + "inner3", + new Dictionary + { + { "deepProp1", "deep-override" }, + { "deepProp3", null }, + { "deepProp4", "new-value" }, + } + }, + } + }, + { "bar", "new-value" }, + { + "baz", + new List { "new", "value" } + }, + }, + }, + }; + + var response = await _rawClient.SendRequestAsync(request); + Assert.That(response.StatusCode, Is.EqualTo(200)); + + var content = await response.Raw.Content.ReadAsStringAsync(); + Assert.That(content, Is.EqualTo("Success")); + + Assert.That(_server.LogEntries.Count, Is.EqualTo(1)); + } + + [TearDown] + public void TearDown() + { + _server.Dispose(); + _httpClient.Dispose(); + } +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Core/RawClientTests/MultipartFormTests.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Core/RawClientTests/MultipartFormTests.cs new file mode 100644 index 000000000000..4d564df8fd61 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Core/RawClientTests/MultipartFormTests.cs @@ -0,0 +1,1120 @@ +using global::System.Net.Http; +using global::System.Text; +using global::System.Text.Json.Serialization; +using NUnit.Framework; +using SeedApi.Core; +using SystemTask = global::System.Threading.Tasks.Task; + +namespace SeedApi.Test.Core.RawClientTests; + +[TestFixture] +[Parallelizable(ParallelScope.Self)] +public class MultipartFormTests +{ + private static SimpleObject _simpleObject = new(); + + private static string _simpleFormEncoded = + "meta=data&Date=2023-10-01&Time=12:00:00&Duration=01:00:00&Id=1a1bb98f-47c6-407b-9481-78476affe52a&IsActive=true&Count=42&Initial=A&Values=data,2023-10-01,12:00:00,01:00:00,1a1bb98f-47c6-407b-9481-78476affe52a,true,42,A"; + + private static string _simpleExplodedFormEncoded = + "meta=data&Date=2023-10-01&Time=12:00:00&Duration=01:00:00&Id=1a1bb98f-47c6-407b-9481-78476affe52a&IsActive=true&Count=42&Initial=A&Values=data&Values=2023-10-01&Values=12:00:00&Values=01:00:00&Values=1a1bb98f-47c6-407b-9481-78476affe52a&Values=true&Values=42&Values=A"; + + private static ComplexObject _complexObject = new(); + + private static string _complexJson = """ + { + "meta": "data", + "Nested": { + "foo": "value" + }, + "NestedDictionary": { + "key": { + "foo": "value" + } + }, + "ListOfObjects": [ + { + "foo": "value" + }, + { + "foo": "value2" + } + ], + "Date": "2023-10-01", + "Time": "12:00:00", + "Duration": "01:00:00", + "Id": "1a1bb98f-47c6-407b-9481-78476affe52a", + "IsActive": true, + "Count": 42, + "Initial": "A" + } + """; + + [Test] + public async SystemTask ShouldAddStringPart() + { + const string partInput = "string content"; + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddStringPart("string", partInput); + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: text/plain + Content-Disposition: form-data; name=string + + {partInput} + --{boundary}-- + """; + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddStringParts() + { + const string partInput = "string content"; + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddStringParts("strings", [partInput, partInput]); + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: text/plain + Content-Disposition: form-data; name=strings + + {partInput} + --{boundary} + Content-Type: text/plain + Content-Disposition: form-data; name=strings + + {partInput} + --{boundary}-- + """; + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask GivenNull_ShouldNotAddStringPart() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddStringPart("string", null); + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + --{boundary}-- + """; + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddStringParts_WithNullsInList() + { + const string partInput = "string content"; + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddStringParts("strings", [partInput, null, partInput]); + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: text/plain + Content-Disposition: form-data; name=strings + + {partInput} + --{boundary} + Content-Type: text/plain + Content-Disposition: form-data; name=strings + + {partInput} + --{boundary}-- + """; + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddStringPart_WithContentType() + { + const string partInput = "string content"; + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddStringPart("string", partInput, "text/xml"); + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: text/xml + Content-Disposition: form-data; name=string + + {partInput} + --{boundary}-- + """; + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddStringPart_WithContentTypeAndCharset() + { + const string partInput = "string content"; + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddStringPart("string", partInput, "text/xml; charset=utf-8"); + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: text/xml; charset=utf-8 + Content-Disposition: form-data; name=string + + {partInput} + --{boundary}-- + """; + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddStringParts_WithContentType() + { + const string partInput = "string content"; + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddStringParts("strings", [partInput, partInput], "text/xml"); + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: text/xml + Content-Disposition: form-data; name=strings + + {partInput} + --{boundary} + Content-Type: text/xml + Content-Disposition: form-data; name=strings + + {partInput} + --{boundary}-- + """; + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddStringParts_WithContentTypeAndCharset() + { + const string partInput = "string content"; + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddStringParts( + "strings", + [partInput, partInput], + "text/xml; charset=utf-8" + ); + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: text/xml; charset=utf-8 + Content-Disposition: form-data; name=strings + + {partInput} + --{boundary} + Content-Type: text/xml; charset=utf-8 + Content-Disposition: form-data; name=strings + + {partInput} + --{boundary}-- + """; + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddFileParameter_WithFileName() + { + var (partInput, partExpectedString) = GetFileParameterTestData(); + var file = new FileParameter { Stream = partInput, FileName = "test.txt" }; + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddFileParameterPart("file", file); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/octet-stream + Content-Disposition: form-data; name=file; filename=test.txt; filename*=utf-8''test.txt + + {partExpectedString} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddFileParameter_WithoutFileName() + { + var (partInput, partExpectedString) = GetFileParameterTestData(); + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddFileParameterPart("file", partInput); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/octet-stream + Content-Disposition: form-data; name=file + + {partExpectedString} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddFileParameter_WithContentType() + { + var (partInput, partExpectedString) = GetFileParameterTestData(); + var file = new FileParameter + { + Stream = partInput, + FileName = "test.txt", + ContentType = "text/plain", + }; + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddFileParameterPart("file", file, "ignored-fallback-content-type"); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: text/plain + Content-Disposition: form-data; name=file; filename=test.txt; filename*=utf-8''test.txt + + {partExpectedString} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddFileParameter_WithContentTypeAndCharset() + { + var (partInput, partExpectedString) = GetFileParameterTestData(); + var file = new FileParameter + { + Stream = partInput, + FileName = "test.txt", + ContentType = "text/plain; charset=utf-8", + }; + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddFileParameterPart("file", file, "ignored-fallback-content-type"); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: text/plain; charset=utf-8 + Content-Disposition: form-data; name=file; filename=test.txt; filename*=utf-8''test.txt + + {partExpectedString} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddFileParameter_WithFallbackContentType() + { + var (partInput, partExpectedString) = GetFileParameterTestData(); + var file = new FileParameter { Stream = partInput, FileName = "test.txt" }; + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddFileParameterPart("file", file, "text/plain"); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: text/plain + Content-Disposition: form-data; name=file; filename=test.txt; filename*=utf-8''test.txt + + {partExpectedString} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddFileParameter_WithFallbackContentTypeAndCharset() + { + var (partInput, partExpectedString) = GetFileParameterTestData(); + var file = new FileParameter { Stream = partInput, FileName = "test.txt" }; + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddFileParameterPart("file", file, "text/plain; charset=utf-8"); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: text/plain; charset=utf-8 + Content-Disposition: form-data; name=file; filename=test.txt; filename*=utf-8''test.txt + + {partExpectedString} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddFileParameters() + { + var (partInput1, partExpectedString1) = GetFileParameterTestData(); + var (partInput2, partExpectedString2) = GetFileParameterTestData(); + var file1 = new FileParameter { Stream = partInput1, FileName = "test1.txt" }; + var file2 = new FileParameter { Stream = partInput2, FileName = "test2.txt" }; + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddFileParameterParts("file", [file1, file2]); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/octet-stream + Content-Disposition: form-data; name=file; filename=test1.txt; filename*=utf-8''test1.txt + + {partExpectedString1} + --{boundary} + Content-Type: application/octet-stream + Content-Disposition: form-data; name=file; filename=test2.txt; filename*=utf-8''test2.txt + + {partExpectedString2} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddFileParameters_WithNullsInList() + { + var (partInput1, partExpectedString1) = GetFileParameterTestData(); + var (partInput2, partExpectedString2) = GetFileParameterTestData(); + var file1 = new FileParameter { Stream = partInput1, FileName = "test1.txt" }; + var file2 = new FileParameter { Stream = partInput2, FileName = "test2.txt" }; + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddFileParameterParts("file", [file1, null, file2]); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/octet-stream + Content-Disposition: form-data; name=file; filename=test1.txt; filename*=utf-8''test1.txt + + {partExpectedString1} + --{boundary} + Content-Type: application/octet-stream + Content-Disposition: form-data; name=file; filename=test2.txt; filename*=utf-8''test2.txt + + {partExpectedString2} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask GivenNull_ShouldNotAddFileParameter() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddFileParameterPart("file", null); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddJsonPart_WithComplexObject() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddJsonPart("object", _complexObject); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/json + Content-Disposition: form-data; name=object + + {_complexJson} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddJsonPart_WithComplexObjectList() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddJsonParts("objects", [_complexObject, _complexObject]); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/json + Content-Disposition: form-data; name=objects + + {_complexJson} + --{boundary} + Content-Type: application/json + Content-Disposition: form-data; name=objects + + {_complexJson} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask GivenNull_ShouldNotAddJsonPart() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddJsonPart("object", null); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddJsonParts_WithNullsInList() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddJsonParts("objects", [_complexObject, null]); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/json + Content-Disposition: form-data; name=objects + + {_complexJson} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddJsonParts_WithContentType() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddJsonParts("objects", [new { }], "application/json-patch+json"); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $$""" + --{{boundary}} + Content-Type: application/json-patch+json + Content-Disposition: form-data; name=objects + + {} + --{{boundary}}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddFormEncodedParts_WithSimpleObject() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddFormEncodedPart("object", _simpleObject); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/x-www-form-urlencoded + Content-Disposition: form-data; name=object + + {EscapeFormEncodedString(_simpleFormEncoded)} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddFormEncodedParts_WithSimpleObjectList() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddFormEncodedParts("objects", [_simpleObject, _simpleObject]); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/x-www-form-urlencoded + Content-Disposition: form-data; name=objects + + {EscapeFormEncodedString(_simpleFormEncoded)} + --{boundary} + Content-Type: application/x-www-form-urlencoded + Content-Disposition: form-data; name=objects + + {EscapeFormEncodedString(_simpleFormEncoded)} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldNotAddFormEncodedParts_WithNull() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddFormEncodedParts("object", null); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldNotAddFormEncodedParts_WithNullsInList() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddFormEncodedParts("objects", [_simpleObject, null]); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/x-www-form-urlencoded + Content-Disposition: form-data; name=objects + + {EscapeFormEncodedString(_simpleFormEncoded)} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddFormEncodedPart_WithContentType() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddFormEncodedPart( + "objects", + new { foo = "bar" }, + "application/x-www-form-urlencoded" + ); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/x-www-form-urlencoded + Content-Disposition: form-data; name=objects + + foo=bar + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddFormEncodedPart_WithContentTypeAndCharset() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddFormEncodedPart( + "objects", + new { foo = "bar" }, + "application/x-www-form-urlencoded; charset=utf-8" + ); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/x-www-form-urlencoded; charset=utf-8 + Content-Disposition: form-data; name=objects + + foo=bar + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddFormEncodedParts_WithContentType() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddFormEncodedParts( + "objects", + [new { foo = "bar" }], + "application/x-www-form-urlencoded" + ); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/x-www-form-urlencoded + Content-Disposition: form-data; name=objects + + foo=bar + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddFormEncodedParts_WithContentTypeAndCharset() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddFormEncodedParts( + "objects", + [new { foo = "bar" }], + "application/x-www-form-urlencoded; charset=utf-8" + ); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/x-www-form-urlencoded; charset=utf-8 + Content-Disposition: form-data; name=objects + + foo=bar + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddExplodedFormEncodedParts_WithSimpleObject() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddExplodedFormEncodedPart("object", _simpleObject); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/x-www-form-urlencoded + Content-Disposition: form-data; name=object + + {EscapeFormEncodedString(_simpleExplodedFormEncoded)} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddExplodedFormEncodedParts_WithSimpleObjectList() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddExplodedFormEncodedParts("objects", [_simpleObject, _simpleObject]); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/x-www-form-urlencoded + Content-Disposition: form-data; name=objects + + {EscapeFormEncodedString(_simpleExplodedFormEncoded)} + --{boundary} + Content-Type: application/x-www-form-urlencoded + Content-Disposition: form-data; name=objects + + {EscapeFormEncodedString(_simpleExplodedFormEncoded)} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldNotAddExplodedFormEncodedParts_WithNull() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddExplodedFormEncodedPart("object", null); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldNotAddExplodedFormEncodedParts_WithNullsInList() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddExplodedFormEncodedParts("objects", [_simpleObject, null]); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/x-www-form-urlencoded + Content-Disposition: form-data; name=objects + + {EscapeFormEncodedString(_simpleExplodedFormEncoded)} + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddExplodedFormEncodedPart_WithContentType() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddExplodedFormEncodedPart( + "objects", + new { foo = "bar" }, + "application/x-www-form-urlencoded" + ); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/x-www-form-urlencoded + Content-Disposition: form-data; name=objects + + foo=bar + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddExplodedFormEncodedPart_WithContentTypeAndCharset() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddExplodedFormEncodedPart( + "objects", + new { foo = "bar" }, + "application/x-www-form-urlencoded; charset=utf-8" + ); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/x-www-form-urlencoded; charset=utf-8 + Content-Disposition: form-data; name=objects + + foo=bar + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddExplodedFormEncodedParts_WithContentType() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddExplodedFormEncodedParts( + "objects", + [new { foo = "bar" }], + "application/x-www-form-urlencoded" + ); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/x-www-form-urlencoded + Content-Disposition: form-data; name=objects + + foo=bar + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + [Test] + public async SystemTask ShouldAddExplodedFormEncodedParts_WithContentTypeAndCharset() + { + var multipartFormRequest = CreateMultipartFormRequest(); + multipartFormRequest.AddExplodedFormEncodedParts( + "objects", + [new { foo = "bar" }], + "application/x-www-form-urlencoded; charset=utf-8" + ); + + var httpContent = multipartFormRequest.CreateContent(); + Assert.That(httpContent, Is.InstanceOf()); + var multipartContent = (MultipartFormDataContent)httpContent; + + var boundary = GetBoundary(multipartContent); + var expected = $""" + --{boundary} + Content-Type: application/x-www-form-urlencoded; charset=utf-8 + Content-Disposition: form-data; name=objects + + foo=bar + --{boundary}-- + """; + + var actual = await multipartContent.ReadAsStringAsync(); + Assert.That(actual, Is.EqualTo(expected).IgnoreWhiteSpace); + } + + private static string EscapeFormEncodedString(string input) + { + return string.Join( + "&", + input + .Split('&') + .Select(x => x.Split('=')) + .Select(x => $"{Uri.EscapeDataString(x[0])}={Uri.EscapeDataString(x[1])}") + ); + } + + private static string GetBoundary(MultipartFormDataContent content) + { + return content + .Headers.ContentType?.Parameters.Single(p => + p.Name.Equals("boundary", StringComparison.OrdinalIgnoreCase) + ) + .Value?.Trim('"') ?? throw new global::System.Exception("Boundary not found"); + } + + private static SeedApi.Core.MultipartFormRequest CreateMultipartFormRequest() + { + return new SeedApi.Core.MultipartFormRequest + { + BaseUrl = "https://localhost", + Method = HttpMethod.Post, + Path = "", + }; + } + + private static (Stream partInput, string partExpectedString) GetFileParameterTestData() + { + const string partExpectedString = "file content"; + var partInput = new MemoryStream(Encoding.Default.GetBytes(partExpectedString)); + return (partInput, partExpectedString); + } + + private class SimpleObject + { + [JsonPropertyName("meta")] + public string Meta { get; set; } = "data"; + public DateOnly Date { get; set; } = DateOnly.Parse("2023-10-01"); + public TimeOnly Time { get; set; } = TimeOnly.Parse("12:00:00"); + public TimeSpan Duration { get; set; } = TimeSpan.FromHours(1); + public Guid Id { get; set; } = Guid.Parse("1a1bb98f-47c6-407b-9481-78476affe52a"); + public bool IsActive { get; set; } = true; + public int Count { get; set; } = 42; + public char Initial { get; set; } = 'A'; + public IEnumerable Values { get; set; } = + [ + "data", + DateOnly.Parse("2023-10-01"), + TimeOnly.Parse("12:00:00"), + TimeSpan.FromHours(1), + Guid.Parse("1a1bb98f-47c6-407b-9481-78476affe52a"), + true, + 42, + 'A', + ]; + } + + private class ComplexObject + { + [JsonPropertyName("meta")] + public string Meta { get; set; } = "data"; + + public object Nested { get; set; } = new { foo = "value" }; + + public Dictionary NestedDictionary { get; set; } = + new() { { "key", new { foo = "value" } } }; + + public IEnumerable ListOfObjects { get; set; } = + new List { new { foo = "value" }, new { foo = "value2" } }; + + public DateOnly Date { get; set; } = DateOnly.Parse("2023-10-01"); + public TimeOnly Time { get; set; } = TimeOnly.Parse("12:00:00"); + public TimeSpan Duration { get; set; } = TimeSpan.FromHours(1); + public Guid Id { get; set; } = Guid.Parse("1a1bb98f-47c6-407b-9481-78476affe52a"); + public bool IsActive { get; set; } = true; + public int Count { get; set; } = 42; + public char Initial { get; set; } = 'A'; + } +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Core/RawClientTests/QueryParameterTests.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Core/RawClientTests/QueryParameterTests.cs new file mode 100644 index 000000000000..bf81c080ce1a --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Core/RawClientTests/QueryParameterTests.cs @@ -0,0 +1,64 @@ +using NUnit.Framework; +using SeedApi.Core; +using WireMock.Matchers; +using WireMock.Server; +using SystemTask = global::System.Threading.Tasks.Task; +using WireMockRequest = WireMock.RequestBuilders.Request; +using WireMockResponse = WireMock.ResponseBuilders.Response; + +namespace SeedApi.Test.Core.RawClientTests; + +[TestFixture] +[Parallelizable(ParallelScope.Self)] +public class QueryParameterTests +{ + private WireMockServer _server; + private HttpClient _httpClient; + private RawClient _rawClient; + private string _baseUrl; + + [SetUp] + public void SetUp() + { + _server = WireMockServer.Start(); + _baseUrl = _server.Url ?? ""; + _httpClient = new HttpClient { BaseAddress = new Uri(_baseUrl) }; + _rawClient = new RawClient(new ClientOptions { HttpClient = _httpClient }); + } + + [Test] + public async SystemTask CreateRequest_QueryParametersEscaping() + { + _server + .Given(WireMockRequest.Create().WithPath("/test").WithParam("foo", "bar").UsingGet()) + .RespondWith(WireMockResponse.Create().WithStatusCode(200).WithBody("Success")); + + var request = new JsonRequest() + { + BaseUrl = _baseUrl, + Method = HttpMethod.Get, + Path = "/test", + Query = new Dictionary + { + { "sample", "value" }, + { "email", "bob+test@example.com" }, + { "%Complete", "100" }, + }, + Options = new RequestOptions(), + }; + + var httpRequest = await _rawClient.CreateHttpRequestAsync(request).ConfigureAwait(false); + var url = httpRequest.RequestUri!.AbsoluteUri; + + Assert.That(url, Does.Contain("sample=value")); + Assert.That(url, Does.Contain("email=bob%2Btest%40example.com")); + Assert.That(url, Does.Contain("%25Complete=100")); + } + + [TearDown] + public void TearDown() + { + _server.Dispose(); + _httpClient.Dispose(); + } +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Core/RawClientTests/RetriesTests.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Core/RawClientTests/RetriesTests.cs new file mode 100644 index 000000000000..0e8f78a07c83 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Core/RawClientTests/RetriesTests.cs @@ -0,0 +1,327 @@ +using global::System.Net.Http; +using NUnit.Framework; +using SeedApi.Core; +using WireMock.Server; +using SystemTask = global::System.Threading.Tasks.Task; +using WireMockRequest = WireMock.RequestBuilders.Request; +using WireMockResponse = WireMock.ResponseBuilders.Response; + +namespace SeedApi.Test.Core.RawClientTests; + +[TestFixture] +[Parallelizable(ParallelScope.Self)] +public class RetriesTests +{ + private const int MaxRetries = 3; + private WireMockServer _server; + private HttpClient _httpClient; + private RawClient _rawClient; + private string _baseUrl; + + [SetUp] + public void SetUp() + { + _server = WireMockServer.Start(); + _baseUrl = _server.Url ?? ""; + _httpClient = new HttpClient { BaseAddress = new Uri(_baseUrl) }; + _rawClient = new RawClient( + new ClientOptions { HttpClient = _httpClient, MaxRetries = MaxRetries } + ) + { + BaseRetryDelay = 0, + }; + } + + [Test] + [TestCase(408)] + [TestCase(429)] + [TestCase(500)] + [TestCase(504)] + public async SystemTask SendRequestAsync_ShouldRetry_OnRetryableStatusCodes(int statusCode) + { + _server + .Given(WireMockRequest.Create().WithPath("/test").UsingGet()) + .InScenario("Retry") + .WillSetStateTo("Server Error") + .RespondWith(WireMockResponse.Create().WithStatusCode(statusCode)); + + _server + .Given(WireMockRequest.Create().WithPath("/test").UsingGet()) + .InScenario("Retry") + .WhenStateIs("Server Error") + .WillSetStateTo("Success") + .RespondWith(WireMockResponse.Create().WithStatusCode(statusCode)); + + _server + .Given(WireMockRequest.Create().WithPath("/test").UsingGet()) + .InScenario("Retry") + .WhenStateIs("Success") + .RespondWith(WireMockResponse.Create().WithStatusCode(200).WithBody("Success")); + + var request = new EmptyRequest + { + BaseUrl = _baseUrl, + Method = HttpMethod.Get, + Path = "/test", + }; + + var response = await _rawClient.SendRequestAsync(request); + Assert.That(response.StatusCode, Is.EqualTo(200)); + + var content = await response.Raw.Content.ReadAsStringAsync(); + using (Assert.EnterMultipleScope()) + { + Assert.That(content, Is.EqualTo("Success")); + + Assert.That(_server.LogEntries, Has.Count.EqualTo(MaxRetries)); + } + } + + [Test] + [TestCase(400)] + [TestCase(409)] + public async SystemTask SendRequestAsync_ShouldRetry_OnNonRetryableStatusCodes(int statusCode) + { + _server + .Given(WireMockRequest.Create().WithPath("/test").UsingGet()) + .InScenario("Retry") + .WillSetStateTo("Server Error") + .RespondWith(WireMockResponse.Create().WithStatusCode(statusCode).WithBody("Failure")); + + var request = new JsonRequest + { + BaseUrl = _baseUrl, + Method = HttpMethod.Get, + Path = "/test", + Body = new { }, + }; + + var response = await _rawClient.SendRequestAsync(request); + Assert.That(response.StatusCode, Is.EqualTo(statusCode)); + + var content = await response.Raw.Content.ReadAsStringAsync(); + Assert.Multiple(() => + { + Assert.That(content, Is.EqualTo("Failure")); + + Assert.That(_server.LogEntries, Has.Count.EqualTo(1)); + }); + } + + [Test] + public async SystemTask SendRequestAsync_ShouldNotRetry_WithStreamRequest() + { + _server + .Given(WireMockRequest.Create().WithPath("/test").UsingPost()) + .InScenario("Retry") + .WillSetStateTo("Server Error") + .RespondWith(WireMockResponse.Create().WithStatusCode(429).WithBody("Failure")); + + var request = new StreamRequest + { + BaseUrl = _baseUrl, + Method = HttpMethod.Post, + Path = "/test", + Body = new MemoryStream(), + }; + + var response = await _rawClient.SendRequestAsync(request); + Assert.That(response.StatusCode, Is.EqualTo(429)); + + var content = await response.Raw.Content.ReadAsStringAsync(); + Assert.Multiple(() => + { + Assert.That(content, Is.EqualTo("Failure")); + Assert.That(_server.LogEntries, Has.Count.EqualTo(1)); + }); + } + + [Test] + public async SystemTask SendRequestAsync_ShouldNotRetry_WithMultiPartFormRequest_WithStream() + { + _server + .Given(WireMockRequest.Create().WithPath("/test").UsingPost()) + .InScenario("Retry") + .WillSetStateTo("Server Error") + .RespondWith(WireMockResponse.Create().WithStatusCode(429).WithBody("Failure")); + + var request = new SeedApi.Core.MultipartFormRequest + { + BaseUrl = _baseUrl, + Method = HttpMethod.Post, + Path = "/test", + }; + request.AddFileParameterPart("file", new MemoryStream()); + + var response = await _rawClient.SendRequestAsync(request); + Assert.That(response.StatusCode, Is.EqualTo(429)); + + var content = await response.Raw.Content.ReadAsStringAsync(); + Assert.Multiple(() => + { + Assert.That(content, Is.EqualTo("Failure")); + Assert.That(_server.LogEntries, Has.Count.EqualTo(1)); + }); + } + + [Test] + public async SystemTask SendRequestAsync_ShouldRetry_WithMultiPartFormRequest_WithoutStream() + { + _server + .Given(WireMockRequest.Create().WithPath("/test").UsingPost()) + .InScenario("Retry") + .WillSetStateTo("Server Error") + .RespondWith(WireMockResponse.Create().WithStatusCode(429)); + + _server + .Given(WireMockRequest.Create().WithPath("/test").UsingPost()) + .InScenario("Retry") + .WhenStateIs("Server Error") + .WillSetStateTo("Success") + .RespondWith(WireMockResponse.Create().WithStatusCode(429)); + + _server + .Given(WireMockRequest.Create().WithPath("/test").UsingPost()) + .InScenario("Retry") + .WhenStateIs("Success") + .RespondWith(WireMockResponse.Create().WithStatusCode(200).WithBody("Success")); + + var request = new SeedApi.Core.MultipartFormRequest + { + BaseUrl = _baseUrl, + Method = HttpMethod.Post, + Path = "/test", + }; + request.AddJsonPart("object", new { }); + + var response = await _rawClient.SendRequestAsync(request); + Assert.That(response.StatusCode, Is.EqualTo(200)); + + var content = await response.Raw.Content.ReadAsStringAsync(); + Assert.Multiple(() => + { + Assert.That(content, Is.EqualTo("Success")); + Assert.That(_server.LogEntries, Has.Count.EqualTo(MaxRetries)); + }); + } + + [Test] + public async SystemTask SendRequestAsync_ShouldRespectRetryAfterHeader_WithSecondsValue() + { + _server + .Given(WireMockRequest.Create().WithPath("/test").UsingGet()) + .InScenario("RetryAfter") + .WillSetStateTo("Success") + .RespondWith( + WireMockResponse.Create().WithStatusCode(429).WithHeader("Retry-After", "1") + ); + + _server + .Given(WireMockRequest.Create().WithPath("/test").UsingGet()) + .InScenario("RetryAfter") + .WhenStateIs("Success") + .RespondWith(WireMockResponse.Create().WithStatusCode(200).WithBody("Success")); + + var request = new EmptyRequest + { + BaseUrl = _baseUrl, + Method = HttpMethod.Get, + Path = "/test", + }; + + var response = await _rawClient.SendRequestAsync(request); + Assert.That(response.StatusCode, Is.EqualTo(200)); + + var content = await response.Raw.Content.ReadAsStringAsync(); + Assert.Multiple(() => + { + Assert.That(content, Is.EqualTo("Success")); + Assert.That(_server.LogEntries, Has.Count.EqualTo(2)); + }); + } + + [Test] + public async SystemTask SendRequestAsync_ShouldRespectRetryAfterHeader_WithHttpDateValue() + { + var retryAfterDate = DateTimeOffset.UtcNow.AddSeconds(1).ToString("R"); + _server + .Given(WireMockRequest.Create().WithPath("/test").UsingGet()) + .InScenario("RetryAfterDate") + .WillSetStateTo("Success") + .RespondWith( + WireMockResponse + .Create() + .WithStatusCode(429) + .WithHeader("Retry-After", retryAfterDate) + ); + + _server + .Given(WireMockRequest.Create().WithPath("/test").UsingGet()) + .InScenario("RetryAfterDate") + .WhenStateIs("Success") + .RespondWith(WireMockResponse.Create().WithStatusCode(200).WithBody("Success")); + + var request = new EmptyRequest + { + BaseUrl = _baseUrl, + Method = HttpMethod.Get, + Path = "/test", + }; + + var response = await _rawClient.SendRequestAsync(request); + Assert.That(response.StatusCode, Is.EqualTo(200)); + + var content = await response.Raw.Content.ReadAsStringAsync(); + Assert.Multiple(() => + { + Assert.That(content, Is.EqualTo("Success")); + Assert.That(_server.LogEntries, Has.Count.EqualTo(2)); + }); + } + + [Test] + public async SystemTask SendRequestAsync_ShouldRespectXRateLimitResetHeader() + { + var resetTime = DateTimeOffset.UtcNow.AddSeconds(1).ToUnixTimeSeconds().ToString(); + _server + .Given(WireMockRequest.Create().WithPath("/test").UsingGet()) + .InScenario("RateLimitReset") + .WillSetStateTo("Success") + .RespondWith( + WireMockResponse + .Create() + .WithStatusCode(429) + .WithHeader("X-RateLimit-Reset", resetTime) + ); + + _server + .Given(WireMockRequest.Create().WithPath("/test").UsingGet()) + .InScenario("RateLimitReset") + .WhenStateIs("Success") + .RespondWith(WireMockResponse.Create().WithStatusCode(200).WithBody("Success")); + + var request = new EmptyRequest + { + BaseUrl = _baseUrl, + Method = HttpMethod.Get, + Path = "/test", + }; + + var response = await _rawClient.SendRequestAsync(request); + Assert.That(response.StatusCode, Is.EqualTo(200)); + + var content = await response.Raw.Content.ReadAsStringAsync(); + Assert.Multiple(() => + { + Assert.That(content, Is.EqualTo("Success")); + Assert.That(_server.LogEntries, Has.Count.EqualTo(2)); + }); + } + + [TearDown] + public void TearDown() + { + _server.Dispose(); + _httpClient.Dispose(); + } +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/SeedApi.Test.Custom.props b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/SeedApi.Test.Custom.props new file mode 100644 index 000000000000..aac9b5020d80 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/SeedApi.Test.Custom.props @@ -0,0 +1,6 @@ + + diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/SeedApi.Test.csproj b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/SeedApi.Test.csproj new file mode 100644 index 000000000000..c14379975861 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/SeedApi.Test.csproj @@ -0,0 +1,36 @@ + + + net8.0 + 12 + enable + enable + false + true + true + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/TestClient.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/TestClient.cs new file mode 100644 index 000000000000..18aaa67904d8 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/TestClient.cs @@ -0,0 +1,6 @@ +using NUnit.Framework; + +namespace SeedApi.Test; + +[TestFixture] +public class TestClient; diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Unit/MockServer/BaseMockServerTest.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Unit/MockServer/BaseMockServerTest.cs new file mode 100644 index 000000000000..cb111a797b9a --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Unit/MockServer/BaseMockServerTest.cs @@ -0,0 +1,38 @@ +using NUnit.Framework; +using SeedApi; +using WireMock.Logging; +using WireMock.Server; +using WireMock.Settings; + +namespace SeedApi.Test.Unit.MockServer; + +[SetUpFixture] +public class BaseMockServerTest +{ + protected static WireMockServer Server { get; set; } = null!; + + protected static SeedApiClient Client { get; set; } = null!; + + protected static RequestOptions RequestOptions { get; set; } = new(); + + [OneTimeSetUp] + public void GlobalSetup() + { + // Start the WireMock server + Server = WireMockServer.Start( + new WireMockServerSettings { Logger = new WireMockConsoleLogger() } + ); + + // Initialize the Client + Client = new SeedApiClient( + clientOptions: new ClientOptions { BaseUrl = Server.Urls[0], MaxRetries = 0 } + ); + } + + [OneTimeTearDown] + public void GlobalTeardown() + { + Server.Stop(); + Server.Dispose(); + } +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Unit/MockServer/GetFooTest.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Unit/MockServer/GetFooTest.cs new file mode 100644 index 000000000000..e15c64168e7b --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Unit/MockServer/GetFooTest.cs @@ -0,0 +1,89 @@ +using NUnit.Framework; +using SeedApi; +using SeedApi.Core; + +namespace SeedApi.Test.Unit.MockServer; + +[TestFixture] +public class GetFooTest : BaseMockServerTest +{ + [NUnit.Framework.Test] + public async Task MockServerTest_1() + { + const string mockResponse = """ + { + "bar": "bar", + "nullable_bar": "nullable_bar", + "nullable_required_bar": "nullable_required_bar", + "required_bar": "required_bar" + } + """; + + Server + .Given( + WireMock + .RequestBuilders.Request.Create() + .WithPath("/foo") + .WithParam("optional_baz", "optional_baz") + .WithParam("optional_nullable_baz", "optional_nullable_baz") + .WithParam("required_baz", "required_baz") + .WithParam("required_nullable_baz", "required_nullable_baz") + .UsingGet() + ) + .RespondWith( + WireMock + .ResponseBuilders.Response.Create() + .WithStatusCode(200) + .WithBody(mockResponse) + ); + + var response = await Client.GetFooAsync( + new GetFooRequest + { + OptionalBaz = "optional_baz", + OptionalNullableBaz = "optional_nullable_baz", + RequiredBaz = "required_baz", + RequiredNullableBaz = "required_nullable_baz", + } + ); + Assert.That(response, Is.EqualTo(JsonUtils.Deserialize(mockResponse)).UsingDefaults()); + } + + [NUnit.Framework.Test] + public async Task MockServerTest_2() + { + const string mockResponse = """ + { + "bar": "bar", + "nullable_bar": "nullable_bar", + "nullable_required_bar": "nullable_required_bar", + "required_bar": "required_bar" + } + """; + + Server + .Given( + WireMock + .RequestBuilders.Request.Create() + .WithPath("/foo") + .WithParam("required_baz", "required_baz") + .WithParam("required_nullable_baz", "required_nullable_baz") + .UsingGet() + ) + .RespondWith( + WireMock + .ResponseBuilders.Response.Create() + .WithStatusCode(200) + .WithBody(mockResponse) + ); + + var response = await Client.GetFooAsync( + new GetFooRequest + { + RequiredBaz = "required_baz", + RequiredNullableBaz = "required_nullable_baz", + } + ); + Assert.That(response, Is.EqualTo(JsonUtils.Deserialize(mockResponse)).UsingDefaults()); + } +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Unit/MockServer/UpdateFooTest.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Unit/MockServer/UpdateFooTest.cs new file mode 100644 index 000000000000..21759cdf3c7b --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Unit/MockServer/UpdateFooTest.cs @@ -0,0 +1,58 @@ +using NUnit.Framework; +using SeedApi; +using SeedApi.Core; + +namespace SeedApi.Test.Unit.MockServer; + +[TestFixture] +public class UpdateFooTest : BaseMockServerTest +{ + [NUnit.Framework.Test] + public async Task MockServerTest() + { + const string requestJson = """ + { + "nullable_text": "nullable_text", + "nullable_number": 1.1, + "non_nullable_text": "non_nullable_text" + } + """; + + const string mockResponse = """ + { + "bar": "bar", + "nullable_bar": "nullable_bar", + "nullable_required_bar": "nullable_required_bar", + "required_bar": "required_bar" + } + """; + + Server + .Given( + WireMock + .RequestBuilders.Request.Create() + .WithPath("/foo/id") + .WithHeader("X-Idempotency-Key", "X-Idempotency-Key") + .UsingPatch() + .WithBodyAsJson(requestJson) + ) + .RespondWith( + WireMock + .ResponseBuilders.Response.Create() + .WithStatusCode(200) + .WithBody(mockResponse) + ); + + var response = await Client.UpdateFooAsync( + "id", + new UpdateFooRequest + { + XIdempotencyKey = "X-Idempotency-Key", + NullableText = "nullable_text", + NullableNumber = 1.1, + NonNullableText = "non_nullable_text", + } + ); + Assert.That(response, Is.EqualTo(JsonUtils.Deserialize(mockResponse)).UsingDefaults()); + } +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Utils/JsonElementComparer.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Utils/JsonElementComparer.cs new file mode 100644 index 000000000000..1704c99af443 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Utils/JsonElementComparer.cs @@ -0,0 +1,236 @@ +using System.Text.Json; +using NUnit.Framework.Constraints; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle JsonElement objects. +/// +public static class JsonElementComparerExtensions +{ + /// + /// Extension method for comparing JsonElement objects in NUnit tests. + /// Property order doesn't matter, but array order does matter. + /// Includes special handling for DateTime string formats. + /// + /// The Is.EqualTo() constraint instance. + /// A constraint that can compare JsonElements with detailed diffs. + public static EqualConstraint UsingJsonElementComparer(this EqualConstraint constraint) + { + return constraint.Using(new JsonElementComparer()); + } +} + +/// +/// Equality comparer for JsonElement with detailed reporting. +/// Property order doesn't matter, but array order does matter. +/// Now includes special handling for DateTime string formats with improved null handling. +/// +public class JsonElementComparer : IEqualityComparer +{ + private string _failurePath = string.Empty; + + /// + public bool Equals(JsonElement x, JsonElement y) + { + _failurePath = string.Empty; + return CompareJsonElements(x, y, string.Empty); + } + + /// + public int GetHashCode(JsonElement obj) + { + return JsonSerializer.Serialize(obj).GetHashCode(); + } + + private bool CompareJsonElements(JsonElement x, JsonElement y, string path) + { + // If value kinds don't match, they're not equivalent + if (x.ValueKind != y.ValueKind) + { + _failurePath = $"{path}: Expected {x.ValueKind} but got {y.ValueKind}"; + return false; + } + + switch (x.ValueKind) + { + case JsonValueKind.Object: + return CompareJsonObjects(x, y, path); + + case JsonValueKind.Array: + return CompareJsonArraysInOrder(x, y, path); + + case JsonValueKind.String: + string? xStr = x.GetString(); + string? yStr = y.GetString(); + + // Handle null strings + if (xStr is null && yStr is null) + return true; + + if (xStr is null || yStr is null) + { + _failurePath = + $"{path}: Expected {(xStr is null ? "null" : $"\"{xStr}\"")} but got {(yStr is null ? "null" : $"\"{yStr}\"")}"; + return false; + } + + // Check if they are identical strings + if (xStr == yStr) + return true; + + // Try to handle DateTime strings + if (IsLikelyDateTimeString(xStr) && IsLikelyDateTimeString(yStr)) + { + if (AreEquivalentDateTimeStrings(xStr, yStr)) + return true; + } + + _failurePath = $"{path}: Expected \"{xStr}\" but got \"{yStr}\""; + return false; + + case JsonValueKind.Number: + if (x.GetDecimal() != y.GetDecimal()) + { + _failurePath = $"{path}: Expected {x.GetDecimal()} but got {y.GetDecimal()}"; + return false; + } + + return true; + + case JsonValueKind.True: + case JsonValueKind.False: + if (x.GetBoolean() != y.GetBoolean()) + { + _failurePath = $"{path}: Expected {x.GetBoolean()} but got {y.GetBoolean()}"; + return false; + } + + return true; + + case JsonValueKind.Null: + return true; + + default: + _failurePath = $"{path}: Unsupported JsonValueKind {x.ValueKind}"; + return false; + } + } + + private bool IsLikelyDateTimeString(string? str) + { + // Simple heuristic to identify likely ISO date time strings + return str != null + && (str.Contains("T") && (str.EndsWith("Z") || str.Contains("+") || str.Contains("-"))); + } + + private bool AreEquivalentDateTimeStrings(string str1, string str2) + { + // Try to parse both as DateTime + if (DateTime.TryParse(str1, out DateTime dt1) && DateTime.TryParse(str2, out DateTime dt2)) + { + return dt1 == dt2; + } + + return false; + } + + private bool CompareJsonObjects(JsonElement x, JsonElement y, string path) + { + // Create dictionaries for both JSON objects + var xProps = new Dictionary(); + var yProps = new Dictionary(); + + foreach (var prop in x.EnumerateObject()) + xProps[prop.Name] = prop.Value; + + foreach (var prop in y.EnumerateObject()) + yProps[prop.Name] = prop.Value; + + // Check if all properties in x exist in y + foreach (var key in xProps.Keys) + { + if (!yProps.ContainsKey(key)) + { + _failurePath = $"{path}: Missing property '{key}'"; + return false; + } + } + + // Check if y has extra properties + foreach (var key in yProps.Keys) + { + if (!xProps.ContainsKey(key)) + { + _failurePath = $"{path}: Unexpected property '{key}'"; + return false; + } + } + + // Compare each property value + foreach (var key in xProps.Keys) + { + var propPath = string.IsNullOrEmpty(path) ? key : $"{path}.{key}"; + if (!CompareJsonElements(xProps[key], yProps[key], propPath)) + { + return false; + } + } + + return true; + } + + private bool CompareJsonArraysInOrder(JsonElement x, JsonElement y, string path) + { + var xArray = x.EnumerateArray(); + var yArray = y.EnumerateArray(); + + // Count x elements + var xCount = 0; + var xElements = new List(); + foreach (var item in xArray) + { + xElements.Add(item); + xCount++; + } + + // Count y elements + var yCount = 0; + var yElements = new List(); + foreach (var item in yArray) + { + yElements.Add(item); + yCount++; + } + + // Check if counts match + if (xCount != yCount) + { + _failurePath = $"{path}: Expected {xCount} items but found {yCount}"; + return false; + } + + // Compare elements in order + for (var i = 0; i < xCount; i++) + { + var itemPath = $"{path}[{i}]"; + if (!CompareJsonElements(xElements[i], yElements[i], itemPath)) + { + return false; + } + } + + return true; + } + + /// + public override string ToString() + { + if (!string.IsNullOrEmpty(_failurePath)) + { + return $"JSON comparison failed at {_failurePath}"; + } + + return "JsonElementEqualityComparer"; + } +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Utils/NUnitExtensions.cs new file mode 100644 index 000000000000..78e90e0a90fc --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Utils/NUnitExtensions.cs @@ -0,0 +1,29 @@ +using NUnit.Framework.Constraints; + +namespace NUnit.Framework; + +/// +/// Extensions for NUnit constraints. +/// +public static class NUnitExtensions +{ + /// + /// Modifies the EqualConstraint to use our own set of default comparers. + /// + /// + /// + public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => + constraint + .UsingPropertiesComparer() + .UsingReadOnlyMemoryComparer() + .UsingReadOnlyMemoryComparer() + .UsingReadOnlyMemoryComparer() + .UsingReadOnlyMemoryComparer() + .UsingReadOnlyMemoryComparer() + .UsingReadOnlyMemoryComparer() + .UsingReadOnlyMemoryComparer() + .UsingReadOnlyMemoryComparer() + .UsingOneOfComparer() + .UsingJsonElementComparer() + .UsingOptionalComparer(); +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Utils/OneOfComparer.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Utils/OneOfComparer.cs new file mode 100644 index 000000000000..0c975b471ff3 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Utils/OneOfComparer.cs @@ -0,0 +1,43 @@ +using NUnit.Framework.Constraints; +using OneOf; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle OneOf values. +/// +public static class EqualConstraintExtensions +{ + /// + /// Modifies the EqualConstraint to handle OneOf instances by comparing their inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOneOfComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOneOf types + constraint.Using( + (x, y) => + { + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (x.Value is null && y.Value is null) + { + return true; + } + + if (x.Value is null) + { + return false; + } + + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(x.Value, y.Value, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..dc732084c15f --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedApi.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Utils/ReadOnlyMemoryComparer.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Utils/ReadOnlyMemoryComparer.cs new file mode 100644 index 000000000000..fc0b595a5e54 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi.Test/Utils/ReadOnlyMemoryComparer.cs @@ -0,0 +1,87 @@ +using NUnit.Framework.Constraints; + +namespace NUnit.Framework; + +/// +/// Extensions for NUnit constraints. +/// +public static class ReadOnlyMemoryComparerExtensions +{ + /// + /// Extension method for comparing ReadOnlyMemory<T> in NUnit tests. + /// + /// The type of elements in the ReadOnlyMemory. + /// The Is.EqualTo() constraint instance. + /// A constraint that can compare ReadOnlyMemory<T>. + public static EqualConstraint UsingReadOnlyMemoryComparer(this EqualConstraint constraint) + where T : IComparable + { + return constraint.Using(new ReadOnlyMemoryComparer()); + } +} + +/// +/// Comparer for ReadOnlyMemory<T>. Compares sequences by value. +/// +/// +/// The type of elements in the ReadOnlyMemory. +/// +public class ReadOnlyMemoryComparer : IComparer> + where T : IComparable +{ + /// + public int Compare(ReadOnlyMemory x, ReadOnlyMemory y) + { + // Check if sequences are equal + var xSpan = x.Span; + var ySpan = y.Span; + + // Optimized case for IEquatable implementations + if (typeof(IEquatable).IsAssignableFrom(typeof(T))) + { + var areEqual = xSpan.SequenceEqual(ySpan); + if (areEqual) + { + return 0; // Sequences are equal + } + } + else + { + // Manual equality check for non-IEquatable types + if (xSpan.Length == ySpan.Length) + { + var areEqual = true; + for (var i = 0; i < xSpan.Length; i++) + { + if (!EqualityComparer.Default.Equals(xSpan[i], ySpan[i])) + { + areEqual = false; + break; + } + } + + if (areEqual) + { + return 0; // Sequences are equal + } + } + } + + // For non-equal sequences, we need to return a consistent ordering + // First compare lengths + if (x.Length != y.Length) + return x.Length.CompareTo(y.Length); + + // Same length but different content - compare first differing element + for (var i = 0; i < x.Length; i++) + { + if (!EqualityComparer.Default.Equals(xSpan[i], ySpan[i])) + { + return xSpan[i].CompareTo(ySpan[i]); + } + } + + // Should never reach here if not equal + return 0; + } +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/ApiResponse.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/ApiResponse.cs new file mode 100644 index 000000000000..33cddd882a72 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/ApiResponse.cs @@ -0,0 +1,13 @@ +using System.Net.Http; + +namespace SeedApi.Core; + +/// +/// The response object returned from the API. +/// +internal record ApiResponse +{ + internal required int StatusCode { get; init; } + + internal required HttpResponseMessage Raw { get; init; } +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/BaseRequest.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/BaseRequest.cs new file mode 100644 index 000000000000..5b3302ff95be --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/BaseRequest.cs @@ -0,0 +1,63 @@ +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; + +namespace SeedApi.Core; + +internal abstract record BaseRequest +{ + internal required string BaseUrl { get; init; } + + internal required HttpMethod Method { get; init; } + + internal required string Path { get; init; } + + internal string? ContentType { get; init; } + + internal Dictionary Query { get; init; } = new(); + + internal Headers Headers { get; init; } = new(); + + internal IRequestOptions? Options { get; init; } + + internal abstract HttpContent? CreateContent(); + + protected static ( + Encoding encoding, + string? charset, + string mediaType + ) ParseContentTypeOrDefault( + string? contentType, + Encoding encodingFallback, + string mediaTypeFallback + ) + { + var encoding = encodingFallback; + var mediaType = mediaTypeFallback; + string? charset = null; + if (string.IsNullOrEmpty(contentType)) + { + return (encoding, charset, mediaType); + } + + if (!MediaTypeHeaderValue.TryParse(contentType, out var mediaTypeHeaderValue)) + { + return (encoding, charset, mediaType); + } + + if (!string.IsNullOrEmpty(mediaTypeHeaderValue.CharSet)) + { + charset = mediaTypeHeaderValue.CharSet; + encoding = Encoding.GetEncoding(mediaTypeHeaderValue.CharSet); + } + + if (!string.IsNullOrEmpty(mediaTypeHeaderValue.MediaType)) + { + mediaType = mediaTypeHeaderValue.MediaType; + } + + return (encoding, charset, mediaType); + } + + protected static Encoding Utf8NoBom => EncodingCache.Utf8NoBom; +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/CollectionItemSerializer.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/CollectionItemSerializer.cs new file mode 100644 index 000000000000..3143228404ae --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/CollectionItemSerializer.cs @@ -0,0 +1,89 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedApi.Core; + +/// +/// Json collection converter. +/// +/// Type of item to convert. +/// Converter to use for individual items. +internal class CollectionItemSerializer + : JsonConverter> + where TConverterType : JsonConverter +{ + /// + /// Reads a json string and deserializes it into an object. + /// + /// Json reader. + /// Type to convert. + /// Serializer options. + /// Created object. + public override IEnumerable? Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return default; + } + + var jsonSerializerOptions = new JsonSerializerOptions(options); + jsonSerializerOptions.Converters.Clear(); + jsonSerializerOptions.Converters.Add(Activator.CreateInstance()); + + var returnValue = new List(); + + while (reader.TokenType != JsonTokenType.EndArray) + { + if (reader.TokenType != JsonTokenType.StartArray) + { + var item = (TDatatype)( + JsonSerializer.Deserialize(ref reader, typeof(TDatatype), jsonSerializerOptions) + ?? throw new global::System.Exception( + $"Failed to deserialize collection item of type {typeof(TDatatype)}" + ) + ); + returnValue.Add(item); + } + + reader.Read(); + } + + return returnValue; + } + + /// + /// Writes a json string. + /// + /// Json writer. + /// Value to write. + /// Serializer options. + public override void Write( + Utf8JsonWriter writer, + IEnumerable? value, + JsonSerializerOptions options + ) + { + if (value == null) + { + writer.WriteNullValue(); + return; + } + + var jsonSerializerOptions = new JsonSerializerOptions(options); + jsonSerializerOptions.Converters.Clear(); + jsonSerializerOptions.Converters.Add(Activator.CreateInstance()); + + writer.WriteStartArray(); + + foreach (var data in value) + { + JsonSerializer.Serialize(writer, data, jsonSerializerOptions); + } + + writer.WriteEndArray(); + } +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/Constants.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/Constants.cs new file mode 100644 index 000000000000..ccf4e963cc89 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/Constants.cs @@ -0,0 +1,7 @@ +namespace SeedApi.Core; + +internal static class Constants +{ + public const string DateTimeFormat = "yyyy'-'MM'-'dd'T'HH':'mm':'ss.fffK"; + public const string DateFormat = "yyyy-MM-dd"; +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/DateOnlyConverter.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/DateOnlyConverter.cs new file mode 100644 index 000000000000..af61cc061ae5 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/DateOnlyConverter.cs @@ -0,0 +1,747 @@ +// ReSharper disable All +#pragma warning disable + +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using global::System.Diagnostics; +using global::System.Diagnostics.CodeAnalysis; +using global::System.Globalization; +using global::System.Runtime.CompilerServices; +using global::System.Runtime.InteropServices; +using global::System.Text.Json; +using global::System.Text.Json.Serialization; + +// ReSharper disable SuggestVarOrType_SimpleTypes +// ReSharper disable SuggestVarOrType_BuiltInTypes + +namespace SeedApi.Core +{ + /// + /// Custom converter for handling the data type with the System.Text.Json library. + /// + /// + /// This class backported from: + /// + /// System.Text.Json.Serialization.Converters.DateOnlyConverter + /// + public sealed class DateOnlyConverter : JsonConverter + { + private const int FormatLength = 10; // YYYY-MM-DD + + private const int MaxEscapedFormatLength = + FormatLength * JsonConstants.MaxExpansionFactorWhileEscaping; + + /// + public override DateOnly Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType != JsonTokenType.String) + { + ThrowHelper.ThrowInvalidOperationException_ExpectedString(reader.TokenType); + } + + return ReadCore(ref reader); + } + + /// + public override DateOnly ReadAsPropertyName( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + Debug.Assert(reader.TokenType == JsonTokenType.PropertyName); + return ReadCore(ref reader); + } + + private static DateOnly ReadCore(ref Utf8JsonReader reader) + { + if ( + !JsonHelpers.IsInRangeInclusive( + reader.ValueLength(), + FormatLength, + MaxEscapedFormatLength + ) + ) + { + ThrowHelper.ThrowFormatException(DataType.DateOnly); + } + + scoped ReadOnlySpan source; + if (!reader.HasValueSequence && !reader.ValueIsEscaped) + { + source = reader.ValueSpan; + } + else + { + Span stackSpan = stackalloc byte[MaxEscapedFormatLength]; + int bytesWritten = reader.CopyString(stackSpan); + source = stackSpan.Slice(0, bytesWritten); + } + + if (!JsonHelpers.TryParseAsIso(source, out DateOnly value)) + { + ThrowHelper.ThrowFormatException(DataType.DateOnly); + } + + return value; + } + + /// + public override void Write( + Utf8JsonWriter writer, + DateOnly value, + JsonSerializerOptions options + ) + { +#if NET8_0_OR_GREATER + Span buffer = stackalloc byte[FormatLength]; +#else + Span buffer = stackalloc char[FormatLength]; +#endif + // ReSharper disable once RedundantAssignment + bool formattedSuccessfully = value.TryFormat( + buffer, + out int charsWritten, + "O".AsSpan(), + CultureInfo.InvariantCulture + ); + Debug.Assert(formattedSuccessfully && charsWritten == FormatLength); + writer.WriteStringValue(buffer); + } + + /// + public override void WriteAsPropertyName( + Utf8JsonWriter writer, + DateOnly value, + JsonSerializerOptions options + ) + { +#if NET8_0_OR_GREATER + Span buffer = stackalloc byte[FormatLength]; +#else + Span buffer = stackalloc char[FormatLength]; +#endif + // ReSharper disable once RedundantAssignment + bool formattedSuccessfully = value.TryFormat( + buffer, + out int charsWritten, + "O".AsSpan(), + CultureInfo.InvariantCulture + ); + Debug.Assert(formattedSuccessfully && charsWritten == FormatLength); + writer.WritePropertyName(buffer); + } + } + + internal static class JsonConstants + { + // The maximum number of fraction digits the Json DateTime parser allows + public const int DateTimeParseNumFractionDigits = 16; + + // In the worst case, an ASCII character represented as a single utf-8 byte could expand 6x when escaped. + public const int MaxExpansionFactorWhileEscaping = 6; + + // The largest fraction expressible by TimeSpan and DateTime formats + public const int MaxDateTimeFraction = 9_999_999; + + // TimeSpan and DateTime formats allow exactly up to many digits for specifying the fraction after the seconds. + public const int DateTimeNumFractionDigits = 7; + + public const byte UtcOffsetToken = (byte)'Z'; + + public const byte TimePrefix = (byte)'T'; + + public const byte Period = (byte)'.'; + + public const byte Hyphen = (byte)'-'; + + public const byte Colon = (byte)':'; + + public const byte Plus = (byte)'+'; + } + + // ReSharper disable SuggestVarOrType_Elsewhere + // ReSharper disable SuggestVarOrType_SimpleTypes + // ReSharper disable SuggestVarOrType_BuiltInTypes + + internal static class JsonHelpers + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsInRangeInclusive(int value, int lowerBound, int upperBound) => + (uint)(value - lowerBound) <= (uint)(upperBound - lowerBound); + + public static bool IsDigit(byte value) => (uint)(value - '0') <= '9' - '0'; + + [StructLayout(LayoutKind.Auto)] + private struct DateTimeParseData + { + public int Year; + public int Month; + public int Day; + public bool IsCalendarDateOnly; + public int Hour; + public int Minute; + public int Second; + public int Fraction; // This value should never be greater than 9_999_999. + public int OffsetHours; + public int OffsetMinutes; + + // ReSharper disable once NotAccessedField.Local + public byte OffsetToken; + } + + public static bool TryParseAsIso(ReadOnlySpan source, out DateOnly value) + { + if ( + TryParseDateTimeOffset(source, out DateTimeParseData parseData) + && parseData.IsCalendarDateOnly + && TryCreateDateTime(parseData, DateTimeKind.Unspecified, out DateTime dateTime) + ) + { + value = DateOnly.FromDateTime(dateTime); + return true; + } + + value = default; + return false; + } + + /// + /// ISO 8601 date time parser (ISO 8601-1:2019). + /// + /// The date/time to parse in UTF-8 format. + /// The parsed for the given . + /// + /// Supports extended calendar date (5.2.2.1) and complete (5.4.2.1) calendar date/time of day + /// representations with optional specification of seconds and fractional seconds. + /// + /// Times can be explicitly specified as UTC ("Z" - 5.3.3) or offsets from UTC ("+/-hh:mm" 5.3.4.2). + /// If unspecified they are considered to be local per spec. + /// + /// Examples: (TZD is either "Z" or hh:mm offset from UTC) + /// + /// YYYY-MM-DD (e.g. 1997-07-16) + /// YYYY-MM-DDThh:mm (e.g. 1997-07-16T19:20) + /// YYYY-MM-DDThh:mm:ss (e.g. 1997-07-16T19:20:30) + /// YYYY-MM-DDThh:mm:ss.s (e.g. 1997-07-16T19:20:30.45) + /// YYYY-MM-DDThh:mmTZD (e.g. 1997-07-16T19:20+01:00) + /// YYYY-MM-DDThh:mm:ssTZD (e.g. 1997-07-16T19:20:3001:00) + /// YYYY-MM-DDThh:mm:ss.sTZD (e.g. 1997-07-16T19:20:30.45Z) + /// + /// Generally speaking we always require the "extended" option when one exists (3.1.3.5). + /// The extended variants have separator characters between components ('-', ':', '.', etc.). + /// Spaces are not permitted. + /// + /// "true" if successfully parsed. + private static bool TryParseDateTimeOffset( + ReadOnlySpan source, + out DateTimeParseData parseData + ) + { + parseData = default; + + // too short datetime + Debug.Assert(source.Length >= 10); + + // Parse the calendar date + // ----------------------- + // ISO 8601-1:2019 5.2.2.1b "Calendar date complete extended format" + // [dateX] = [year]["-"][month]["-"][day] + // [year] = [YYYY] [0000 - 9999] (4.3.2) + // [month] = [MM] [01 - 12] (4.3.3) + // [day] = [DD] [01 - 28, 29, 30, 31] (4.3.4) + // + // Note: 5.2.2.2 "Representations with reduced precision" allows for + // just [year]["-"][month] (a) and just [year] (b), but we currently + // don't permit it. + + { + uint digit1 = source[0] - (uint)'0'; + uint digit2 = source[1] - (uint)'0'; + uint digit3 = source[2] - (uint)'0'; + uint digit4 = source[3] - (uint)'0'; + + if (digit1 > 9 || digit2 > 9 || digit3 > 9 || digit4 > 9) + { + return false; + } + + parseData.Year = (int)(digit1 * 1000 + digit2 * 100 + digit3 * 10 + digit4); + } + + if ( + source[4] != JsonConstants.Hyphen + || !TryGetNextTwoDigits(source.Slice(start: 5, length: 2), ref parseData.Month) + || source[7] != JsonConstants.Hyphen + || !TryGetNextTwoDigits(source.Slice(start: 8, length: 2), ref parseData.Day) + ) + { + return false; + } + + // We now have YYYY-MM-DD [dateX] + // ReSharper disable once ConvertIfStatementToSwitchStatement + if (source.Length == 10) + { + parseData.IsCalendarDateOnly = true; + return true; + } + + // Parse the time of day + // --------------------- + // + // ISO 8601-1:2019 5.3.1.2b "Local time of day complete extended format" + // [timeX] = ["T"][hour][":"][min][":"][sec] + // [hour] = [hh] [00 - 23] (4.3.8a) + // [minute] = [mm] [00 - 59] (4.3.9a) + // [sec] = [ss] [00 - 59, 60 with a leap second] (4.3.10a) + // + // ISO 8601-1:2019 5.3.3 "UTC of day" + // [timeX]["Z"] + // + // ISO 8601-1:2019 5.3.4.2 "Local time of day with the time shift between + // local timescale and UTC" (Extended format) + // + // [shiftX] = ["+"|"-"][hour][":"][min] + // + // Notes: + // + // "T" is optional per spec, but _only_ when times are used alone. In our + // case, we're reading out a complete date & time and as such require "T". + // (5.4.2.1b). + // + // For [timeX] We allow seconds to be omitted per 5.3.1.3a "Representations + // with reduced precision". 5.3.1.3b allows just specifying the hour, but + // we currently don't permit this. + // + // Decimal fractions are allowed for hours, minutes and seconds (5.3.14). + // We only allow fractions for seconds currently. Lower order components + // can't follow, i.e. you can have T23.3, but not T23.3:04. There must be + // one digit, but the max number of digits is implementation defined. We + // currently allow up to 16 digits of fractional seconds only. While we + // support 16 fractional digits we only parse the first seven, anything + // past that is considered a zero. This is to stay compatible with the + // DateTime implementation which is limited to this resolution. + + if (source.Length < 16) + { + // Source does not have enough characters for YYYY-MM-DDThh:mm + return false; + } + + // Parse THH:MM (e.g. "T10:32") + if ( + source[10] != JsonConstants.TimePrefix + || source[13] != JsonConstants.Colon + || !TryGetNextTwoDigits(source.Slice(start: 11, length: 2), ref parseData.Hour) + || !TryGetNextTwoDigits(source.Slice(start: 14, length: 2), ref parseData.Minute) + ) + { + return false; + } + + // We now have YYYY-MM-DDThh:mm + Debug.Assert(source.Length >= 16); + if (source.Length == 16) + { + return true; + } + + byte curByte = source[16]; + int sourceIndex = 17; + + // Either a TZD ['Z'|'+'|'-'] or a seconds separator [':'] is valid at this point + switch (curByte) + { + case JsonConstants.UtcOffsetToken: + parseData.OffsetToken = JsonConstants.UtcOffsetToken; + return sourceIndex == source.Length; + case JsonConstants.Plus: + case JsonConstants.Hyphen: + parseData.OffsetToken = curByte; + return ParseOffset(ref parseData, source.Slice(sourceIndex)); + case JsonConstants.Colon: + break; + default: + return false; + } + + // Try reading the seconds + if ( + source.Length < 19 + || !TryGetNextTwoDigits(source.Slice(start: 17, length: 2), ref parseData.Second) + ) + { + return false; + } + + // We now have YYYY-MM-DDThh:mm:ss + Debug.Assert(source.Length >= 19); + if (source.Length == 19) + { + return true; + } + + curByte = source[19]; + sourceIndex = 20; + + // Either a TZD ['Z'|'+'|'-'] or a seconds decimal fraction separator ['.'] is valid at this point + switch (curByte) + { + case JsonConstants.UtcOffsetToken: + parseData.OffsetToken = JsonConstants.UtcOffsetToken; + return sourceIndex == source.Length; + case JsonConstants.Plus: + case JsonConstants.Hyphen: + parseData.OffsetToken = curByte; + return ParseOffset(ref parseData, source.Slice(sourceIndex)); + case JsonConstants.Period: + break; + default: + return false; + } + + // Source does not have enough characters for second fractions (i.e. ".s") + // YYYY-MM-DDThh:mm:ss.s + if (source.Length < 21) + { + return false; + } + + // Parse fraction. This value should never be greater than 9_999_999 + int numDigitsRead = 0; + int fractionEnd = Math.Min( + sourceIndex + JsonConstants.DateTimeParseNumFractionDigits, + source.Length + ); + + while (sourceIndex < fractionEnd && IsDigit(curByte = source[sourceIndex])) + { + if (numDigitsRead < JsonConstants.DateTimeNumFractionDigits) + { + parseData.Fraction = parseData.Fraction * 10 + (int)(curByte - (uint)'0'); + numDigitsRead++; + } + + sourceIndex++; + } + + if (parseData.Fraction != 0) + { + while (numDigitsRead < JsonConstants.DateTimeNumFractionDigits) + { + parseData.Fraction *= 10; + numDigitsRead++; + } + } + + // We now have YYYY-MM-DDThh:mm:ss.s + Debug.Assert(sourceIndex <= source.Length); + if (sourceIndex == source.Length) + { + return true; + } + + curByte = source[sourceIndex++]; + + // TZD ['Z'|'+'|'-'] is valid at this point + switch (curByte) + { + case JsonConstants.UtcOffsetToken: + parseData.OffsetToken = JsonConstants.UtcOffsetToken; + return sourceIndex == source.Length; + case JsonConstants.Plus: + case JsonConstants.Hyphen: + parseData.OffsetToken = curByte; + return ParseOffset(ref parseData, source.Slice(sourceIndex)); + default: + return false; + } + + static bool ParseOffset(ref DateTimeParseData parseData, ReadOnlySpan offsetData) + { + // Parse the hours for the offset + if ( + offsetData.Length < 2 + || !TryGetNextTwoDigits(offsetData.Slice(0, 2), ref parseData.OffsetHours) + ) + { + return false; + } + + // We now have YYYY-MM-DDThh:mm:ss.s+|-hh + + if (offsetData.Length == 2) + { + // Just hours offset specified + return true; + } + + // Ensure we have enough for ":mm" + return offsetData.Length == 5 + && offsetData[2] == JsonConstants.Colon + && TryGetNextTwoDigits(offsetData.Slice(3), ref parseData.OffsetMinutes); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + // ReSharper disable once RedundantAssignment + private static bool TryGetNextTwoDigits(ReadOnlySpan source, ref int value) + { + Debug.Assert(source.Length == 2); + + uint digit1 = source[0] - (uint)'0'; + uint digit2 = source[1] - (uint)'0'; + + if (digit1 > 9 || digit2 > 9) + { + value = 0; + return false; + } + + value = (int)(digit1 * 10 + digit2); + return true; + } + + // The following methods are borrowed verbatim from src/Common/src/CoreLib/System/Buffers/Text/Utf8Parser/Utf8Parser.Date.Helpers.cs + + /// + /// Overflow-safe DateTime factory. + /// + private static bool TryCreateDateTime( + DateTimeParseData parseData, + DateTimeKind kind, + out DateTime value + ) + { + if (parseData.Year == 0) + { + value = default; + return false; + } + + Debug.Assert(parseData.Year <= 9999); // All of our callers to date parse the year from fixed 4-digit fields so this value is trusted. + + if ((uint)parseData.Month - 1 >= 12) + { + value = default; + return false; + } + + uint dayMinusOne = (uint)parseData.Day - 1; + if ( + dayMinusOne >= 28 + && dayMinusOne >= DateTime.DaysInMonth(parseData.Year, parseData.Month) + ) + { + value = default; + return false; + } + + if ((uint)parseData.Hour > 23) + { + value = default; + return false; + } + + if ((uint)parseData.Minute > 59) + { + value = default; + return false; + } + + // This needs to allow leap seconds when appropriate. + // See https://github.com/dotnet/runtime/issues/30135. + if ((uint)parseData.Second > 59) + { + value = default; + return false; + } + + Debug.Assert(parseData.Fraction is >= 0 and <= JsonConstants.MaxDateTimeFraction); // All of our callers to date parse the fraction from fixed 7-digit fields so this value is trusted. + + ReadOnlySpan days = DateTime.IsLeapYear(parseData.Year) + ? DaysToMonth366 + : DaysToMonth365; + int yearMinusOne = parseData.Year - 1; + int totalDays = + yearMinusOne * 365 + + yearMinusOne / 4 + - yearMinusOne / 100 + + yearMinusOne / 400 + + days[parseData.Month - 1] + + parseData.Day + - 1; + long ticks = totalDays * TimeSpan.TicksPerDay; + int totalSeconds = parseData.Hour * 3600 + parseData.Minute * 60 + parseData.Second; + ticks += totalSeconds * TimeSpan.TicksPerSecond; + ticks += parseData.Fraction; + value = new DateTime(ticks: ticks, kind: kind); + return true; + } + + private static ReadOnlySpan DaysToMonth365 => + [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334, 365]; + private static ReadOnlySpan DaysToMonth366 => + [0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335, 366]; + } + + internal static class ThrowHelper + { + private const string ExceptionSourceValueToRethrowAsJsonException = + "System.Text.Json.Rethrowable"; + + [DoesNotReturn] + public static void ThrowInvalidOperationException_ExpectedString(JsonTokenType tokenType) + { + throw GetInvalidOperationException("string", tokenType); + } + + public static void ThrowFormatException(DataType dataType) + { + throw new FormatException(SR.Format(SR.UnsupportedFormat, dataType)) + { + Source = ExceptionSourceValueToRethrowAsJsonException, + }; + } + + private static global::System.Exception GetInvalidOperationException( + string message, + JsonTokenType tokenType + ) + { + return GetInvalidOperationException(SR.Format(SR.InvalidCast, tokenType, message)); + } + + private static InvalidOperationException GetInvalidOperationException(string message) + { + return new InvalidOperationException(message) + { + Source = ExceptionSourceValueToRethrowAsJsonException, + }; + } + } + + internal static class Utf8JsonReaderExtensions + { + internal static int ValueLength(this Utf8JsonReader reader) => + reader.HasValueSequence + ? checked((int)reader.ValueSequence.Length) + : reader.ValueSpan.Length; + } + + internal enum DataType + { + TimeOnly, + DateOnly, + } + + [SuppressMessage("ReSharper", "InconsistentNaming")] + internal static class SR + { + private static readonly bool s_usingResourceKeys = + AppContext.TryGetSwitch( + "System.Resources.UseSystemResourceKeys", + out bool usingResourceKeys + ) && usingResourceKeys; + + public static string UnsupportedFormat => Strings.UnsupportedFormat; + + public static string InvalidCast => Strings.InvalidCast; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static string Format(string resourceFormat, object? p1) => + s_usingResourceKeys + ? string.Join(", ", resourceFormat, p1) + : string.Format(resourceFormat, p1); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static string Format(string resourceFormat, object? p1, object? p2) => + s_usingResourceKeys + ? string.Join(", ", resourceFormat, p1, p2) + : string.Format(resourceFormat, p1, p2); + } + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute( + "System.Resources.Tools.StronglyTypedResourceBuilder", + "17.0.0.0" + )] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Strings + { + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute( + "Microsoft.Performance", + "CA1811:AvoidUncalledPrivateCode" + )] + internal Strings() { } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute( + global::System.ComponentModel.EditorBrowsableState.Advanced + )] + internal static global::System.Resources.ResourceManager ResourceManager + { + get + { + if (object.ReferenceEquals(resourceMan, null)) + { + global::System.Resources.ResourceManager temp = + new global::System.Resources.ResourceManager( + "System.Text.Json.Resources.Strings", + typeof(Strings).Assembly + ); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute( + global::System.ComponentModel.EditorBrowsableState.Advanced + )] + internal static global::System.Globalization.CultureInfo Culture + { + get { return resourceCulture; } + set { resourceCulture = value; } + } + + /// + /// Looks up a localized string similar to Cannot get the value of a token type '{0}' as a {1}.. + /// + internal static string InvalidCast + { + get { return ResourceManager.GetString("InvalidCast", resourceCulture); } + } + + /// + /// Looks up a localized string similar to The JSON value is not in a supported {0} format.. + /// + internal static string UnsupportedFormat + { + get { return ResourceManager.GetString("UnsupportedFormat", resourceCulture); } + } + } +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/DateTimeSerializer.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/DateTimeSerializer.cs new file mode 100644 index 000000000000..27ad6dc8f212 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/DateTimeSerializer.cs @@ -0,0 +1,22 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedApi.Core; + +internal class DateTimeSerializer : JsonConverter +{ + public override DateTime Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + return DateTime.Parse(reader.GetString()!, null, DateTimeStyles.RoundtripKind); + } + + public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString(Constants.DateTimeFormat)); + } +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/EmptyRequest.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/EmptyRequest.cs new file mode 100644 index 000000000000..71836f056999 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/EmptyRequest.cs @@ -0,0 +1,11 @@ +using System.Net.Http; + +namespace SeedApi.Core; + +/// +/// The request object to send without a request body. +/// +internal record EmptyRequest : BaseRequest +{ + internal override HttpContent? CreateContent() => null; +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/EncodingCache.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/EncodingCache.cs new file mode 100644 index 000000000000..742352a41fa0 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/EncodingCache.cs @@ -0,0 +1,11 @@ +using System.Text; + +namespace SeedApi.Core; + +internal static class EncodingCache +{ + internal static readonly Encoding Utf8NoBom = new UTF8Encoding( + encoderShouldEmitUTF8Identifier: false, + throwOnInvalidBytes: true + ); +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/Extensions.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/Extensions.cs new file mode 100644 index 000000000000..58e39dfde73e --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/Extensions.cs @@ -0,0 +1,55 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.Serialization; + +namespace SeedApi.Core; + +internal static class Extensions +{ + public static string Stringify(this Enum value) + { + var field = value.GetType().GetField(value.ToString()); + if (field != null) + { + var attribute = (EnumMemberAttribute?) + global::System.Attribute.GetCustomAttribute(field, typeof(EnumMemberAttribute)); + return attribute?.Value ?? value.ToString(); + } + return value.ToString(); + } + + /// + /// Asserts that a condition is true, throwing an exception with the specified message if it is false. + /// + /// The condition to assert. + /// The exception message if the assertion fails. + /// Thrown when the condition is false. + internal static void Assert(this object value, bool condition, string message) + { + if (!condition) + { + throw new global::System.Exception(message); + } + } + + /// + /// Asserts that a value is not null, throwing an exception with the specified message if it is null. + /// + /// The type of the value to assert. + /// The value to assert is not null. + /// The exception message if the assertion fails. + /// The non-null value. + /// Thrown when the value is null. + internal static TValue Assert( + this object _unused, + [NotNull] TValue? value, + string message + ) + where TValue : class + { + if (value == null) + { + throw new global::System.Exception(message); + } + return value; + } +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/FormUrlEncoder.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/FormUrlEncoder.cs new file mode 100644 index 000000000000..343c13716c24 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/FormUrlEncoder.cs @@ -0,0 +1,33 @@ +using global::System.Net.Http; + +namespace SeedApi.Core; + +/// +/// Encodes an object into a form URL-encoded content. +/// +public static class FormUrlEncoder +{ + /// + /// Encodes an object into a form URL-encoded content using Deep Object notation. + /// + /// Object to form URL-encode. You can pass in an object or dictionary, but not lists, strings, or primitives. + /// Throws when passing in a list, a string, or a primitive value. + internal static FormUrlEncodedContent EncodeAsDeepObject(object value) => + new(QueryStringConverter.ToDeepObject(value)); + + /// + /// Encodes an object into a form URL-encoded content using Exploded Form notation. + /// + /// Object to form URL-encode. You can pass in an object or dictionary, but not lists, strings, or primitives. + /// Throws when passing in a list, a string, or a primitive value. + internal static FormUrlEncodedContent EncodeAsExplodedForm(object value) => + new(QueryStringConverter.ToExplodedForm(value)); + + /// + /// Encodes an object into a form URL-encoded content using Form notation without exploding parameters. + /// + /// Object to form URL-encode. You can pass in an object or dictionary, but not lists, strings, or primitives. + /// Throws when passing in a list, a string, or a primitive value. + internal static FormUrlEncodedContent EncodeAsForm(object value) => + new(QueryStringConverter.ToForm(value)); +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/HeaderValue.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/HeaderValue.cs new file mode 100644 index 000000000000..5557a60477d3 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/HeaderValue.cs @@ -0,0 +1,41 @@ +using OneOf; + +namespace SeedApi.Core; + +internal sealed class HeaderValue( + OneOf< + string, + Func, + Func>, + Func> + > value +) + : OneOfBase< + string, + Func, + Func>, + Func> + >(value) +{ + public static implicit operator HeaderValue(string value) => new(value); + + public static implicit operator HeaderValue(Func value) => new(value); + + public static implicit operator HeaderValue( + Func> value + ) => new(value); + + public static implicit operator HeaderValue( + Func> value + ) => new(value); + + internal global::System.Threading.Tasks.ValueTask ResolveAsync() + { + return Match( + str => new global::System.Threading.Tasks.ValueTask(str), + syncFunc => new global::System.Threading.Tasks.ValueTask(syncFunc()), + valueTaskFunc => valueTaskFunc(), + taskFunc => new global::System.Threading.Tasks.ValueTask(taskFunc()) + ); + } +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/Headers.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/Headers.cs new file mode 100644 index 000000000000..23f3f758ee88 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/Headers.cs @@ -0,0 +1,28 @@ +namespace SeedApi.Core; + +/// +/// Represents the headers sent with the request. +/// +internal sealed class Headers : Dictionary +{ + internal Headers() { } + + /// + /// Initializes a new instance of the Headers class with the specified value. + /// + /// + internal Headers(Dictionary value) + { + foreach (var kvp in value) + { + this[kvp.Key] = new HeaderValue(kvp.Value); + } + } + + /// + /// Initializes a new instance of the Headers class with the specified value. + /// + /// + internal Headers(IEnumerable> value) + : base(value.ToDictionary(e => e.Key, e => e.Value)) { } +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/HttpMethodExtensions.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/HttpMethodExtensions.cs new file mode 100644 index 000000000000..130464dace1a --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/HttpMethodExtensions.cs @@ -0,0 +1,8 @@ +using System.Net.Http; + +namespace SeedApi.Core; + +internal static class HttpMethodExtensions +{ + public static readonly HttpMethod Patch = new("PATCH"); +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/IIsRetryableContent.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/IIsRetryableContent.cs new file mode 100644 index 000000000000..1a5d48064427 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/IIsRetryableContent.cs @@ -0,0 +1,6 @@ +namespace SeedApi.Core; + +public interface IIsRetryableContent +{ + public bool IsRetryable { get; } +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/IRequestOptions.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/IRequestOptions.cs new file mode 100644 index 000000000000..b2b0c6562843 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/IRequestOptions.cs @@ -0,0 +1,88 @@ +namespace SeedApi.Core; + +internal interface IRequestOptions +{ + /// + /// The Base URL for the API. + /// + public string? BaseUrl { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } + + /// + /// The http client used to make requests. + /// + public HttpClient? HttpClient { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } + + /// + /// The http headers sent with the request. + /// + internal Headers Headers { get; init; } + + /// + /// Additional headers to be sent with the request. + /// Headers previously set with matching keys will be overwritten. + /// + public IEnumerable> AdditionalHeaders { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } + + /// + /// The http client used to make requests. + /// + public int? MaxRetries { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } + + /// + /// The timeout for the request. + /// + public TimeSpan? Timeout { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } + + /// + /// Additional query parameters sent with the request. + /// + public IEnumerable> AdditionalQueryParameters { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } + + /// + /// Additional body properties sent with the request. + /// This is only applied to JSON requests. + /// + public object? AdditionalBodyProperties { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/JsonAccessAttribute.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/JsonAccessAttribute.cs new file mode 100644 index 000000000000..93dcc6dd6bca --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/JsonAccessAttribute.cs @@ -0,0 +1,15 @@ +namespace SeedApi.Core; + +[global::System.AttributeUsage( + global::System.AttributeTargets.Property | global::System.AttributeTargets.Field +)] +internal class JsonAccessAttribute(JsonAccessType accessType) : global::System.Attribute +{ + internal JsonAccessType AccessType { get; init; } = accessType; +} + +internal enum JsonAccessType +{ + ReadOnly, + WriteOnly, +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/JsonConfiguration.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/JsonConfiguration.cs new file mode 100644 index 000000000000..df6b2c46945d --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/JsonConfiguration.cs @@ -0,0 +1,251 @@ +using global::System.Reflection; +using global::System.Text.Json; +using global::System.Text.Json.Nodes; +using global::System.Text.Json.Serialization; +using global::System.Text.Json.Serialization.Metadata; + +namespace SeedApi.Core; + +internal static partial class JsonOptions +{ + internal static readonly JsonSerializerOptions JsonSerializerOptions; + + static JsonOptions() + { + var options = new JsonSerializerOptions + { + Converters = + { + new DateTimeSerializer(), +#if USE_PORTABLE_DATE_ONLY + new DateOnlyConverter(), +#endif + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, +#if DEBUG + WriteIndented = true, +#endif + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = new DefaultJsonTypeInfoResolver + { + Modifiers = + { + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, + }, + }, + }; + ConfigureJsonSerializerOptions(options); + JsonSerializerOptions = options; + } + + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); +} + +internal static class JsonUtils +{ + internal static string Serialize(T obj) => + JsonSerializer.Serialize(obj, JsonOptions.JsonSerializerOptions); + + internal static JsonElement SerializeToElement(T obj) => + JsonSerializer.SerializeToElement(obj, JsonOptions.JsonSerializerOptions); + + internal static JsonDocument SerializeToDocument(T obj) => + JsonSerializer.SerializeToDocument(obj, JsonOptions.JsonSerializerOptions); + + internal static JsonNode? SerializeToNode(T obj) => + JsonSerializer.SerializeToNode(obj, JsonOptions.JsonSerializerOptions); + + internal static byte[] SerializeToUtf8Bytes(T obj) => + JsonSerializer.SerializeToUtf8Bytes(obj, JsonOptions.JsonSerializerOptions); + + internal static string SerializeWithAdditionalProperties( + T obj, + object? additionalProperties = null + ) + { + if (additionalProperties == null) + { + return Serialize(obj); + } + var additionalPropertiesJsonNode = SerializeToNode(additionalProperties); + if (additionalPropertiesJsonNode is not JsonObject additionalPropertiesJsonObject) + { + throw new InvalidOperationException( + "The additional properties must serialize to a JSON object." + ); + } + var jsonNode = SerializeToNode(obj); + if (jsonNode is not JsonObject jsonObject) + { + throw new InvalidOperationException( + "The serialized object must be a JSON object to add properties." + ); + } + MergeJsonObjects(jsonObject, additionalPropertiesJsonObject); + return jsonObject.ToJsonString(JsonOptions.JsonSerializerOptions); + } + + private static void MergeJsonObjects(JsonObject baseObject, JsonObject overrideObject) + { + foreach (var property in overrideObject) + { + if (!baseObject.TryGetPropertyValue(property.Key, out JsonNode? existingValue)) + { + baseObject[property.Key] = + property.Value != null ? JsonNode.Parse(property.Value.ToJsonString()) : null; + continue; + } + if ( + existingValue is JsonObject nestedBaseObject + && property.Value is JsonObject nestedOverrideObject + ) + { + // If both values are objects, recursively merge them. + MergeJsonObjects(nestedBaseObject, nestedOverrideObject); + continue; + } + // Otherwise, the overrideObject takes precedence. + baseObject[property.Key] = + property.Value != null ? JsonNode.Parse(property.Value.ToJsonString()) : null; + } + } + + internal static T Deserialize(string json) => + JsonSerializer.Deserialize(json, JsonOptions.JsonSerializerOptions)!; +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/JsonRequest.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/JsonRequest.cs new file mode 100644 index 000000000000..9512f99d2fae --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/JsonRequest.cs @@ -0,0 +1,36 @@ +using System.Net.Http; + +namespace SeedApi.Core; + +/// +/// The request object to be sent for JSON APIs. +/// +internal record JsonRequest : BaseRequest +{ + internal object? Body { get; init; } + + internal override HttpContent? CreateContent() + { + if (Body is null && Options?.AdditionalBodyProperties is null) + { + return null; + } + + var (encoding, charset, mediaType) = ParseContentTypeOrDefault( + ContentType, + Utf8NoBom, + "application/json" + ); + var content = new StringContent( + JsonUtils.SerializeWithAdditionalProperties(Body, Options?.AdditionalBodyProperties), + encoding, + mediaType + ); + if (string.IsNullOrEmpty(charset) && content.Headers.ContentType is not null) + { + content.Headers.ContentType.CharSet = ""; + } + + return content; + } +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/MultipartFormRequest.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/MultipartFormRequest.cs new file mode 100644 index 000000000000..bdf87da57747 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/MultipartFormRequest.cs @@ -0,0 +1,294 @@ +using System.Net.Http; +using System.Net.Http.Headers; + +namespace SeedApi.Core; + +/// +/// The request object to be sent for multipart form data. +/// +internal record MultipartFormRequest : BaseRequest +{ + private readonly List> _partAdders = []; + + internal void AddJsonPart(string name, object? value) => AddJsonPart(name, value, null); + + internal void AddJsonPart(string name, object? value, string? contentType) + { + if (value is null) + { + return; + } + + _partAdders.Add(form => + { + var (encoding, charset, mediaType) = ParseContentTypeOrDefault( + contentType, + Utf8NoBom, + "application/json" + ); + var content = new StringContent(JsonUtils.Serialize(value), encoding, mediaType); + if (string.IsNullOrEmpty(charset) && content.Headers.ContentType is not null) + { + content.Headers.ContentType.CharSet = ""; + } + + form.Add(content, name); + }); + } + + internal void AddJsonParts(string name, IEnumerable? value) => + AddJsonParts(name, value, null); + + internal void AddJsonParts(string name, IEnumerable? value, string? contentType) + { + if (value is null) + { + return; + } + + foreach (var item in value) + { + AddJsonPart(name, item, contentType); + } + } + + internal void AddJsonParts(string name, IEnumerable? value) => + AddJsonParts(name, value, null); + + internal void AddJsonParts(string name, IEnumerable? value, string? contentType) + { + if (value is null) + { + return; + } + + foreach (var item in value) + { + AddJsonPart(name, item, contentType); + } + } + + internal void AddStringPart(string name, object? value) => AddStringPart(name, value, null); + + internal void AddStringPart(string name, object? value, string? contentType) + { + if (value is null) + { + return; + } + + AddStringPart(name, ValueConvert.ToString(value), contentType); + } + + internal void AddStringPart(string name, string? value) => AddStringPart(name, value, null); + + internal void AddStringPart(string name, string? value, string? contentType) + { + if (value is null) + { + return; + } + + _partAdders.Add(form => + { + var (encoding, charset, mediaType) = ParseContentTypeOrDefault( + contentType, + Utf8NoBom, + "text/plain" + ); + var content = new StringContent(value, encoding, mediaType); + if (string.IsNullOrEmpty(charset) && content.Headers.ContentType is not null) + { + content.Headers.ContentType.CharSet = ""; + } + + form.Add(content, name); + }); + } + + internal void AddStringParts(string name, IEnumerable? value) => + AddStringParts(name, value, null); + + internal void AddStringParts(string name, IEnumerable? value, string? contentType) + { + if (value is null) + { + return; + } + + AddStringPart(name, ValueConvert.ToString(value), contentType); + } + + internal void AddStringParts(string name, IEnumerable? value) => + AddStringParts(name, value, null); + + internal void AddStringParts(string name, IEnumerable? value, string? contentType) + { + if (value is null) + { + return; + } + + foreach (var item in value) + { + AddStringPart(name, item, contentType); + } + } + + internal void AddStreamPart(string name, Stream? stream, string? fileName) => + AddStreamPart(name, stream, fileName, null); + + internal void AddStreamPart(string name, Stream? stream, string? fileName, string? contentType) + { + if (stream is null) + { + return; + } + + _partAdders.Add(form => + { + var content = new StreamContent(stream) + { + Headers = + { + ContentType = MediaTypeHeaderValue.Parse( + contentType ?? "application/octet-stream" + ), + }, + }; + + if (fileName is not null) + { + form.Add(content, name, fileName); + } + else + { + form.Add(content, name); + } + }); + } + + internal void AddFileParameterPart(string name, Stream? stream) => + AddStreamPart(name, stream, null, null); + + internal void AddFileParameterPart(string name, FileParameter? file) => + AddFileParameterPart(name, file, null); + + internal void AddFileParameterPart( + string name, + FileParameter? file, + string? fallbackContentType + ) => + AddStreamPart(name, file?.Stream, file?.FileName, file?.ContentType ?? fallbackContentType); + + internal void AddFileParameterParts(string name, IEnumerable? files) => + AddFileParameterParts(name, files, null); + + internal void AddFileParameterParts( + string name, + IEnumerable? files, + string? fallbackContentType + ) + { + if (files is null) + { + return; + } + + foreach (var file in files) + { + AddFileParameterPart(name, file, fallbackContentType); + } + } + + internal void AddFormEncodedPart(string name, object? value) => + AddFormEncodedPart(name, value, null); + + internal void AddFormEncodedPart(string name, object? value, string? contentType) + { + if (value is null) + { + return; + } + + _partAdders.Add(form => + { + var content = FormUrlEncoder.EncodeAsForm(value); + if (!string.IsNullOrEmpty(contentType)) + { + content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType); + } + + form.Add(content, name); + }); + } + + internal void AddFormEncodedParts(string name, IEnumerable? value) => + AddFormEncodedParts(name, value, null); + + internal void AddFormEncodedParts(string name, IEnumerable? value, string? contentType) + { + if (value is null) + { + return; + } + + foreach (var item in value) + { + AddFormEncodedPart(name, item, contentType); + } + } + + internal void AddExplodedFormEncodedPart(string name, object? value) => + AddExplodedFormEncodedPart(name, value, null); + + internal void AddExplodedFormEncodedPart(string name, object? value, string? contentType) + { + if (value is null) + { + return; + } + + _partAdders.Add(form => + { + var content = FormUrlEncoder.EncodeAsExplodedForm(value); + if (!string.IsNullOrEmpty(contentType)) + { + content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType); + } + + form.Add(content, name); + }); + } + + internal void AddExplodedFormEncodedParts(string name, IEnumerable? value) => + AddExplodedFormEncodedParts(name, value, null); + + internal void AddExplodedFormEncodedParts( + string name, + IEnumerable? value, + string? contentType + ) + { + if (value is null) + { + return; + } + + foreach (var item in value) + { + AddExplodedFormEncodedPart(name, item, contentType); + } + } + + internal override HttpContent CreateContent() + { + var form = new MultipartFormDataContent(); + foreach (var adder in _partAdders) + { + adder(form); + } + + return form; + } +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/NullableAttribute.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/NullableAttribute.cs new file mode 100644 index 000000000000..8fca30535a2a --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedApi.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/OneOfSerializer.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/OneOfSerializer.cs new file mode 100644 index 000000000000..8b6c65cb9b99 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/OneOfSerializer.cs @@ -0,0 +1,91 @@ +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using OneOf; + +namespace SeedApi.Core; + +internal class OneOfSerializer : JsonConverter +{ + public override IOneOf? Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType is JsonTokenType.Null) + return default; + + foreach (var (type, cast) in GetOneOfTypes(typeToConvert)) + { + try + { + var readerCopy = reader; + var result = JsonSerializer.Deserialize(ref readerCopy, type, options); + reader.Skip(); + return (IOneOf)cast.Invoke(null, [result])!; + } + catch (JsonException) { } + } + + throw new JsonException( + $"Cannot deserialize into one of the supported types for {typeToConvert}" + ); + } + + public override void Write(Utf8JsonWriter writer, IOneOf value, JsonSerializerOptions options) + { + JsonSerializer.Serialize(writer, value.Value, options); + } + + private static (global::System.Type type, MethodInfo cast)[] GetOneOfTypes( + global::System.Type typeToConvert + ) + { + var type = typeToConvert; + if (Nullable.GetUnderlyingType(type) is { } underlyingType) + { + type = underlyingType; + } + + var casts = type.GetRuntimeMethods() + .Where(m => m.IsSpecialName && m.Name == "op_Implicit") + .ToArray(); + while (type != null) + { + if ( + type.IsGenericType + && (type.Name.StartsWith("OneOf`") || type.Name.StartsWith("OneOfBase`")) + ) + { + var genericArguments = type.GetGenericArguments(); + if (genericArguments.Length == 1) + { + return [(genericArguments[0], casts[0])]; + } + + // if object type is present, make sure it is last + var indexOfObjectType = Array.IndexOf(genericArguments, typeof(object)); + if (indexOfObjectType != -1 && genericArguments.Length - 1 != indexOfObjectType) + { + genericArguments = genericArguments + .OrderBy(t => t == typeof(object) ? 1 : 0) + .ToArray(); + } + + return genericArguments + .Select(t => (t, casts.First(c => c.GetParameters()[0].ParameterType == t))) + .ToArray(); + } + + type = type.BaseType; + } + + throw new InvalidOperationException($"{type} isn't OneOf or OneOfBase"); + } + + public override bool CanConvert(global::System.Type typeToConvert) + { + return typeof(IOneOf).IsAssignableFrom(typeToConvert); + } +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/Optional.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/Optional.cs new file mode 100644 index 000000000000..4a9691feca1f --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedApi.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/OptionalAttribute.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..b2bc05f7244f --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedApi.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/Public/AdditionalProperties.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/Public/AdditionalProperties.cs new file mode 100644 index 000000000000..8b43322350bd --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/Public/AdditionalProperties.cs @@ -0,0 +1,353 @@ +using global::System.Collections; +using global::System.Collections.ObjectModel; +using global::System.Text.Json; +using global::System.Text.Json.Nodes; +using SeedApi.Core; + +namespace SeedApi; + +public record ReadOnlyAdditionalProperties : ReadOnlyAdditionalProperties +{ + internal ReadOnlyAdditionalProperties() { } + + internal ReadOnlyAdditionalProperties(IDictionary properties) + : base(properties) { } +} + +public record ReadOnlyAdditionalProperties : IReadOnlyDictionary +{ + private readonly Dictionary _extensionData = new(); + private readonly Dictionary _convertedCache = new(); + + internal ReadOnlyAdditionalProperties() + { + _extensionData = new Dictionary(); + _convertedCache = new Dictionary(); + } + + internal ReadOnlyAdditionalProperties(IDictionary properties) + { + _extensionData = new Dictionary(properties.Count); + _convertedCache = new Dictionary(properties.Count); + foreach (var kvp in properties) + { + if (kvp.Value is JsonElement element) + { + _extensionData.Add(kvp.Key, element); + } + else + { + _extensionData[kvp.Key] = JsonUtils.SerializeToElement(kvp.Value); + } + + _convertedCache[kvp.Key] = kvp.Value; + } + } + + private static T ConvertToT(JsonElement value) + { + if (typeof(T) == typeof(JsonElement)) + { + return (T)(object)value; + } + + return value.Deserialize(JsonOptions.JsonSerializerOptions)!; + } + + internal void CopyFromExtensionData(IDictionary extensionData) + { + _extensionData.Clear(); + _convertedCache.Clear(); + foreach (var kvp in extensionData) + { + _extensionData[kvp.Key] = kvp.Value; + if (kvp.Value is T value) + { + _convertedCache[kvp.Key] = value; + } + } + } + + private T GetCached(string key) + { + if (_convertedCache.TryGetValue(key, out var cached)) + { + return cached; + } + + var value = ConvertToT(_extensionData[key]); + _convertedCache[key] = value; + return value; + } + + public IEnumerator> GetEnumerator() + { + return _extensionData + .Select(kvp => new KeyValuePair(kvp.Key, GetCached(kvp.Key))) + .GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + public int Count => _extensionData.Count; + + public bool ContainsKey(string key) => _extensionData.ContainsKey(key); + + public bool TryGetValue(string key, out T value) + { + if (_convertedCache.TryGetValue(key, out value!)) + { + return true; + } + + if (_extensionData.TryGetValue(key, out var element)) + { + value = ConvertToT(element); + _convertedCache[key] = value; + return true; + } + + return false; + } + + public T this[string key] => GetCached(key); + + public IEnumerable Keys => _extensionData.Keys; + + public IEnumerable Values => Keys.Select(GetCached); +} + +public record AdditionalProperties : AdditionalProperties +{ + public AdditionalProperties() { } + + public AdditionalProperties(IDictionary properties) + : base(properties) { } +} + +public record AdditionalProperties : IDictionary +{ + private readonly Dictionary _extensionData; + private readonly Dictionary _convertedCache; + + public AdditionalProperties() + { + _extensionData = new Dictionary(); + _convertedCache = new Dictionary(); + } + + public AdditionalProperties(IDictionary properties) + { + _extensionData = new Dictionary(properties.Count); + _convertedCache = new Dictionary(properties.Count); + foreach (var kvp in properties) + { + _extensionData[kvp.Key] = kvp.Value; + _convertedCache[kvp.Key] = kvp.Value; + } + } + + private static T ConvertToT(object? extensionDataValue) + { + return extensionDataValue switch + { + T value => value, + JsonElement jsonElement => jsonElement.Deserialize( + JsonOptions.JsonSerializerOptions + )!, + JsonNode jsonNode => jsonNode.Deserialize(JsonOptions.JsonSerializerOptions)!, + _ => JsonUtils + .SerializeToElement(extensionDataValue) + .Deserialize(JsonOptions.JsonSerializerOptions)!, + }; + } + + internal void CopyFromExtensionData(IDictionary extensionData) + { + _extensionData.Clear(); + _convertedCache.Clear(); + foreach (var kvp in extensionData) + { + _extensionData[kvp.Key] = kvp.Value; + if (kvp.Value is T value) + { + _convertedCache[kvp.Key] = value; + } + } + } + + internal void CopyToExtensionData(IDictionary extensionData) + { + extensionData.Clear(); + foreach (var kvp in _extensionData) + { + extensionData[kvp.Key] = kvp.Value; + } + } + + public JsonObject ToJsonObject() => + ( + JsonUtils.SerializeToNode(_extensionData) + ?? throw new InvalidOperationException( + "Failed to serialize AdditionalProperties to JSON Node." + ) + ).AsObject(); + + public JsonNode ToJsonNode() => + JsonUtils.SerializeToNode(_extensionData) + ?? throw new InvalidOperationException( + "Failed to serialize AdditionalProperties to JSON Node." + ); + + public JsonElement ToJsonElement() => JsonUtils.SerializeToElement(_extensionData); + + public JsonDocument ToJsonDocument() => JsonUtils.SerializeToDocument(_extensionData); + + public IReadOnlyDictionary ToJsonElementDictionary() + { + return new ReadOnlyDictionary( + _extensionData.ToDictionary( + kvp => kvp.Key, + kvp => + { + if (kvp.Value is JsonElement jsonElement) + { + return jsonElement; + } + + return JsonUtils.SerializeToElement(kvp.Value); + } + ) + ); + } + + public ICollection Keys => _extensionData.Keys; + + public ICollection Values + { + get + { + var values = new T[_extensionData.Count]; + var i = 0; + foreach (var key in Keys) + { + values[i++] = GetCached(key); + } + + return values; + } + } + + private T GetCached(string key) + { + if (_convertedCache.TryGetValue(key, out var value)) + { + return value; + } + + value = ConvertToT(_extensionData[key]); + _convertedCache.Add(key, value); + return value; + } + + private void SetCached(string key, T value) + { + _extensionData[key] = value; + _convertedCache[key] = value; + } + + private void AddCached(string key, T value) + { + _extensionData.Add(key, value); + _convertedCache.Add(key, value); + } + + private bool RemoveCached(string key) + { + var isRemoved = _extensionData.Remove(key); + _convertedCache.Remove(key); + return isRemoved; + } + + public int Count => _extensionData.Count; + public bool IsReadOnly => false; + + public T this[string key] + { + get => GetCached(key); + set => SetCached(key, value); + } + + public void Add(string key, T value) => AddCached(key, value); + + public void Add(KeyValuePair item) => AddCached(item.Key, item.Value); + + public bool Remove(string key) => RemoveCached(key); + + public bool Remove(KeyValuePair item) => RemoveCached(item.Key); + + public bool ContainsKey(string key) => _extensionData.ContainsKey(key); + + public bool Contains(KeyValuePair item) + { + return _extensionData.ContainsKey(item.Key) + && EqualityComparer.Default.Equals(GetCached(item.Key), item.Value); + } + + public bool TryGetValue(string key, out T value) + { + if (_convertedCache.TryGetValue(key, out value!)) + { + return true; + } + + if (_extensionData.TryGetValue(key, out var extensionDataValue)) + { + value = ConvertToT(extensionDataValue); + _convertedCache[key] = value; + return true; + } + + return false; + } + + public void Clear() + { + _extensionData.Clear(); + _convertedCache.Clear(); + } + + public void CopyTo(KeyValuePair[] array, int arrayIndex) + { + if (array is null) + { + throw new ArgumentNullException(nameof(array)); + } + + if (arrayIndex < 0 || arrayIndex > array.Length) + { + throw new ArgumentOutOfRangeException(nameof(arrayIndex)); + } + + if (array.Length - arrayIndex < _extensionData.Count) + { + throw new ArgumentException( + "The array does not have enough space to copy the elements." + ); + } + + foreach (var kvp in _extensionData) + { + array[arrayIndex++] = new KeyValuePair(kvp.Key, GetCached(kvp.Key)); + } + } + + public IEnumerator> GetEnumerator() + { + return _extensionData + .Select(kvp => new KeyValuePair(kvp.Key, GetCached(kvp.Key))) + .GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/Public/ClientOptions.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/Public/ClientOptions.cs new file mode 100644 index 000000000000..b794283a74fa --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/Public/ClientOptions.cs @@ -0,0 +1,83 @@ +using SeedApi.Core; + +namespace SeedApi; + +[Serializable] +public partial class ClientOptions +{ + /// + /// The http headers sent with the request. + /// + internal Headers Headers { get; init; } = new(); + + /// + /// The Base URL for the API. + /// + public string BaseUrl { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } = ""; + + /// + /// The http client used to make requests. + /// + public HttpClient HttpClient { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } = new HttpClient(); + + /// + /// Additional headers to be sent with HTTP requests. + /// Headers with matching keys will be overwritten by headers set on the request. + /// + public IEnumerable> AdditionalHeaders { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } = []; + + /// + /// The http client used to make requests. + /// + public int MaxRetries { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } = 2; + + /// + /// The timeout for the request. + /// + public TimeSpan Timeout { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } = TimeSpan.FromSeconds(30); + + /// + /// Clones this and returns a new instance + /// + internal ClientOptions Clone() + { + return new ClientOptions + { + BaseUrl = BaseUrl, + HttpClient = HttpClient, + MaxRetries = MaxRetries, + Timeout = Timeout, + Headers = new Headers(new Dictionary(Headers)), + }; + } +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/Public/FileParameter.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/Public/FileParameter.cs new file mode 100644 index 000000000000..f33d49028884 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/Public/FileParameter.cs @@ -0,0 +1,63 @@ +namespace SeedApi; + +/// +/// File parameter for uploading files. +/// +public record FileParameter : IDisposable +#if NET6_0_OR_GREATER + , IAsyncDisposable +#endif +{ + private bool _disposed; + + /// + /// The name of the file to be uploaded. + /// + public string? FileName { get; set; } + + /// + /// The content type of the file to be uploaded. + /// + public string? ContentType { get; set; } + + /// + /// The content of the file to be uploaded. + /// + public required Stream Stream { get; set; } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + protected virtual void Dispose(bool disposing) + { + if (_disposed) + return; + if (disposing) + { + Stream.Dispose(); + } + + _disposed = true; + } + +#if NET6_0_OR_GREATER + /// + public async ValueTask DisposeAsync() + { + if (!_disposed) + { + await Stream.DisposeAsync().ConfigureAwait(false); + _disposed = true; + } + + GC.SuppressFinalize(this); + } +#endif + + public static implicit operator FileParameter(Stream stream) => new() { Stream = stream }; +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/Public/RequestOptions.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/Public/RequestOptions.cs new file mode 100644 index 000000000000..9143dadc4a99 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/Public/RequestOptions.cs @@ -0,0 +1,91 @@ +using SeedApi.Core; + +namespace SeedApi; + +[Serializable] +public partial class RequestOptions : IRequestOptions +{ + /// + /// The http headers sent with the request. + /// + Headers IRequestOptions.Headers { get; init; } = new(); + + /// + /// The Base URL for the API. + /// + public string? BaseUrl { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } + + /// + /// The http client used to make requests. + /// + public HttpClient? HttpClient { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } + + /// + /// Additional headers to be sent with the request. + /// Headers previously set with matching keys will be overwritten. + /// + public IEnumerable> AdditionalHeaders { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } = []; + + /// + /// The http client used to make requests. + /// + public int? MaxRetries { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } + + /// + /// The timeout for the request. + /// + public TimeSpan? Timeout { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } + + /// + /// Additional query parameters sent with the request. + /// + public IEnumerable> AdditionalQueryParameters { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } = Enumerable.Empty>(); + + /// + /// Additional body properties sent with the request. + /// This is only applied to JSON requests. + /// + public object? AdditionalBodyProperties { get; +#if NET5_0_OR_GREATER + init; +#else + set; +#endif + } +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/Public/SeedApiApiException.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/Public/SeedApiApiException.cs new file mode 100644 index 000000000000..8c81259a7880 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/Public/SeedApiApiException.cs @@ -0,0 +1,18 @@ +namespace SeedApi; + +/// +/// This exception type will be thrown for any non-2XX API responses. +/// +public class SeedApiApiException(string message, int statusCode, object body) + : SeedApiException(message) +{ + /// + /// The error code of the response that triggered the exception. + /// + public int StatusCode => statusCode; + + /// + /// The body of the response that triggered the exception. + /// + public object Body => body; +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/Public/SeedApiException.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/Public/SeedApiException.cs new file mode 100644 index 000000000000..90e03e71e695 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/Public/SeedApiException.cs @@ -0,0 +1,7 @@ +namespace SeedApi; + +/// +/// Base exception class for all exceptions thrown by the SDK. +/// +public class SeedApiException(string message, Exception? innerException = null) + : Exception(message, innerException); diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/Public/Version.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/Public/Version.cs new file mode 100644 index 000000000000..3d210b7e0b4c --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/Public/Version.cs @@ -0,0 +1,7 @@ +namespace SeedApi; + +[Serializable] +internal class Version +{ + public const string Current = "0.0.1"; +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/QueryStringConverter.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/QueryStringConverter.cs new file mode 100644 index 000000000000..881f9595a403 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/QueryStringConverter.cs @@ -0,0 +1,229 @@ +using global::System.Text.Json; + +namespace SeedApi.Core; + +/// +/// Converts an object into a query string collection. +/// +internal static class QueryStringConverter +{ + /// + /// Converts an object into a query string collection using Deep Object notation. + /// + /// Object to form URL-encode. You can pass in an object or dictionary, but not lists, strings, or primitives. + /// Throws when passing in a list, a string, or a primitive value. + /// A collection of key value pairs. The keys and values are not URL encoded. + internal static IEnumerable> ToDeepObject(object value) + { + var queryCollection = new List>(); + var json = JsonUtils.SerializeToElement(value); + AssertRootJson(json); + JsonToDeepObject(json, "", queryCollection); + return queryCollection; + } + + /// + /// Converts an object into a query string collection using Exploded Form notation. + /// + /// Object to form URL-encode. You can pass in an object or dictionary, but not lists, strings, or primitives. + /// Throws when passing in a list, a string, or a primitive value. + /// A collection of key value pairs. The keys and values are not URL encoded. + internal static IEnumerable> ToExplodedForm(object value) + { + var queryCollection = new List>(); + var json = JsonUtils.SerializeToElement(value); + AssertRootJson(json); + JsonToFormExploded(json, "", queryCollection); + return queryCollection; + } + + /// + /// Converts an object into a query string collection using Form notation without exploding parameters. + /// + /// Object to form URL-encode. You can pass in an object or dictionary, but not lists, strings, or primitives. + /// Throws when passing in a list, a string, or a primitive value. + /// A collection of key value pairs. The keys and values are not URL encoded. + internal static IEnumerable> ToForm(object value) + { + var queryCollection = new List>(); + var json = JsonUtils.SerializeToElement(value); + AssertRootJson(json); + JsonToForm(json, "", queryCollection); + return queryCollection; + } + + private static void AssertRootJson(JsonElement json) + { + switch (json.ValueKind) + { + case JsonValueKind.Object: + break; + case JsonValueKind.Array: + case JsonValueKind.Undefined: + case JsonValueKind.String: + case JsonValueKind.Number: + case JsonValueKind.True: + case JsonValueKind.False: + case JsonValueKind.Null: + default: + throw new global::System.Exception( + $"Only objects can be converted to query string collections. Given type is {json.ValueKind}." + ); + } + } + + private static void JsonToForm( + JsonElement element, + string prefix, + List> parameters + ) + { + switch (element.ValueKind) + { + case JsonValueKind.Object: + foreach (var property in element.EnumerateObject()) + { + var newPrefix = string.IsNullOrEmpty(prefix) + ? property.Name + : $"{prefix}[{property.Name}]"; + + JsonToForm(property.Value, newPrefix, parameters); + } + break; + case JsonValueKind.Array: + var arrayValues = element.EnumerateArray().Select(ValueToString).ToArray(); + parameters.Add( + new KeyValuePair(prefix, string.Join(",", arrayValues)) + ); + break; + case JsonValueKind.Null: + break; + case JsonValueKind.Undefined: + case JsonValueKind.String: + case JsonValueKind.Number: + case JsonValueKind.True: + case JsonValueKind.False: + default: + parameters.Add(new KeyValuePair(prefix, ValueToString(element))); + break; + } + } + + private static void JsonToFormExploded( + JsonElement element, + string prefix, + List> parameters + ) + { + switch (element.ValueKind) + { + case JsonValueKind.Object: + foreach (var property in element.EnumerateObject()) + { + var newPrefix = string.IsNullOrEmpty(prefix) + ? property.Name + : $"{prefix}[{property.Name}]"; + + JsonToFormExploded(property.Value, newPrefix, parameters); + } + + break; + case JsonValueKind.Array: + foreach (var item in element.EnumerateArray()) + { + if ( + item.ValueKind != JsonValueKind.Object + && item.ValueKind != JsonValueKind.Array + ) + { + parameters.Add( + new KeyValuePair(prefix, ValueToString(item)) + ); + } + else + { + JsonToFormExploded(item, prefix, parameters); + } + } + + break; + case JsonValueKind.Null: + break; + case JsonValueKind.Undefined: + case JsonValueKind.String: + case JsonValueKind.Number: + case JsonValueKind.True: + case JsonValueKind.False: + default: + parameters.Add(new KeyValuePair(prefix, ValueToString(element))); + break; + } + } + + private static void JsonToDeepObject( + JsonElement element, + string prefix, + List> parameters + ) + { + switch (element.ValueKind) + { + case JsonValueKind.Object: + foreach (var property in element.EnumerateObject()) + { + var newPrefix = string.IsNullOrEmpty(prefix) + ? property.Name + : $"{prefix}[{property.Name}]"; + + JsonToDeepObject(property.Value, newPrefix, parameters); + } + + break; + case JsonValueKind.Array: + var index = 0; + foreach (var item in element.EnumerateArray()) + { + var newPrefix = $"{prefix}[{index++}]"; + + if ( + item.ValueKind != JsonValueKind.Object + && item.ValueKind != JsonValueKind.Array + ) + { + parameters.Add( + new KeyValuePair(newPrefix, ValueToString(item)) + ); + } + else + { + JsonToDeepObject(item, newPrefix, parameters); + } + } + + break; + case JsonValueKind.Null: + break; + case JsonValueKind.Undefined: + case JsonValueKind.String: + case JsonValueKind.Number: + case JsonValueKind.True: + case JsonValueKind.False: + default: + parameters.Add(new KeyValuePair(prefix, ValueToString(element))); + break; + } + } + + private static string ValueToString(JsonElement element) + { + return element.ValueKind switch + { + JsonValueKind.String => element.GetString() ?? "", + JsonValueKind.Number => element.GetRawText(), + JsonValueKind.True => "true", + JsonValueKind.False => "false", + JsonValueKind.Null => "", + _ => element.GetRawText(), + }; + } +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/RawClient.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/RawClient.cs new file mode 100644 index 000000000000..b60968e4eb03 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/RawClient.cs @@ -0,0 +1,506 @@ +using global::System.Net.Http; +using global::System.Net.Http.Headers; +using global::System.Text; +using SystemTask = global::System.Threading.Tasks.Task; + +namespace SeedApi.Core; + +/// +/// Utility class for making raw HTTP requests to the API. +/// +internal partial class RawClient(ClientOptions clientOptions) +{ + private const int MaxRetryDelayMs = 60000; + private const double JitterFactor = 0.2; +#if NET6_0_OR_GREATER + // Use Random.Shared for thread-safe random number generation on .NET 6+ +#else + private static readonly object JitterLock = new(); + private static readonly Random JitterRandom = new(); +#endif + internal int BaseRetryDelay { get; set; } = 1000; + + /// + /// The client options applied on every request. + /// + internal readonly ClientOptions Options = clientOptions; + + [Obsolete("Use SendRequestAsync instead.")] + internal global::System.Threading.Tasks.Task MakeRequestAsync( + global::SeedApi.Core.BaseRequest request, + CancellationToken cancellationToken = default + ) + { + return SendRequestAsync(request, cancellationToken); + } + + internal async global::System.Threading.Tasks.Task SendRequestAsync( + global::SeedApi.Core.BaseRequest request, + CancellationToken cancellationToken = default + ) + { + // Apply the request timeout. + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var timeout = request.Options?.Timeout ?? Options.Timeout; + cts.CancelAfter(timeout); + + var httpRequest = await CreateHttpRequestAsync(request).ConfigureAwait(false); + // Send the request. + return await SendWithRetriesAsync(httpRequest, request.Options, cts.Token) + .ConfigureAwait(false); + } + + internal async global::System.Threading.Tasks.Task SendRequestAsync( + HttpRequestMessage request, + IRequestOptions? options, + CancellationToken cancellationToken = default + ) + { + // Apply the request timeout. + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var timeout = options?.Timeout ?? Options.Timeout; + cts.CancelAfter(timeout); + + // Send the request. + return await SendWithRetriesAsync(request, options, cts.Token).ConfigureAwait(false); + } + + private static async global::System.Threading.Tasks.Task CloneRequestAsync( + HttpRequestMessage request + ) + { + var clonedRequest = new HttpRequestMessage(request.Method, request.RequestUri); + clonedRequest.Version = request.Version; + switch (request.Content) + { + case MultipartContent oldMultipartFormContent: + var originalBoundary = + oldMultipartFormContent + .Headers.ContentType?.Parameters.First(p => + p.Name.Equals("boundary", StringComparison.OrdinalIgnoreCase) + ) + .Value?.Trim('"') ?? Guid.NewGuid().ToString(); + var newMultipartContent = oldMultipartFormContent switch + { + MultipartFormDataContent => new MultipartFormDataContent(originalBoundary), + _ => new MultipartContent(), + }; + foreach (var content in oldMultipartFormContent) + { + var ms = new MemoryStream(); + await content.CopyToAsync(ms).ConfigureAwait(false); + ms.Position = 0; + var newPart = new StreamContent(ms); + foreach (var header in oldMultipartFormContent.Headers) + { + newPart.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + + newMultipartContent.Add(newPart); + } + + clonedRequest.Content = newMultipartContent; + break; + default: + clonedRequest.Content = request.Content; + break; + } + + foreach (var header in request.Headers) + { + clonedRequest.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + + return clonedRequest; + } + + /// + /// Sends the request with retries, unless the request content is not retryable, + /// such as stream requests and multipart form data with stream content. + /// + private async global::System.Threading.Tasks.Task SendWithRetriesAsync( + HttpRequestMessage request, + IRequestOptions? options, + CancellationToken cancellationToken + ) + { + var httpClient = options?.HttpClient ?? Options.HttpClient; + var maxRetries = options?.MaxRetries ?? Options.MaxRetries; + var response = await httpClient + .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); + var isRetryableContent = IsRetryableContent(request); + + if (!isRetryableContent) + { + return new global::SeedApi.Core.ApiResponse + { + StatusCode = (int)response.StatusCode, + Raw = response, + }; + } + + for (var i = 0; i < maxRetries; i++) + { + if (!ShouldRetry(response)) + { + break; + } + + var delayMs = GetRetryDelayFromHeaders(response, i); + await SystemTask.Delay(delayMs, cancellationToken).ConfigureAwait(false); + using var retryRequest = await CloneRequestAsync(request).ConfigureAwait(false); + response = await httpClient + .SendAsync( + retryRequest, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken + ) + .ConfigureAwait(false); + } + + return new global::SeedApi.Core.ApiResponse + { + StatusCode = (int)response.StatusCode, + Raw = response, + }; + } + + private static bool ShouldRetry(HttpResponseMessage response) + { + var statusCode = (int)response.StatusCode; + return statusCode is 408 or 429 or >= 500; + } + + private static int AddPositiveJitter(int delayMs) + { +#if NET6_0_OR_GREATER + var random = Random.Shared.NextDouble(); +#else + double random; + lock (JitterLock) + { + random = JitterRandom.NextDouble(); + } +#endif + var jitterMultiplier = 1 + random * JitterFactor; + return (int)(delayMs * jitterMultiplier); + } + + private static int AddSymmetricJitter(int delayMs) + { +#if NET6_0_OR_GREATER + var random = Random.Shared.NextDouble(); +#else + double random; + lock (JitterLock) + { + random = JitterRandom.NextDouble(); + } +#endif + var jitterMultiplier = 1 + (random - 0.5) * JitterFactor; + return (int)(delayMs * jitterMultiplier); + } + + private int GetRetryDelayFromHeaders(HttpResponseMessage response, int retryAttempt) + { + if (response.Headers.TryGetValues("Retry-After", out var retryAfterValues)) + { + var retryAfter = retryAfterValues.FirstOrDefault(); + if (!string.IsNullOrEmpty(retryAfter)) + { + if (int.TryParse(retryAfter, out var retryAfterSeconds) && retryAfterSeconds > 0) + { + return Math.Min(retryAfterSeconds * 1000, MaxRetryDelayMs); + } + + if (DateTimeOffset.TryParse(retryAfter, out var retryAfterDate)) + { + var delay = (int)(retryAfterDate - DateTimeOffset.UtcNow).TotalMilliseconds; + if (delay > 0) + { + return Math.Min(delay, MaxRetryDelayMs); + } + } + } + } + + if (response.Headers.TryGetValues("X-RateLimit-Reset", out var rateLimitResetValues)) + { + var rateLimitReset = rateLimitResetValues.FirstOrDefault(); + if ( + !string.IsNullOrEmpty(rateLimitReset) + && long.TryParse(rateLimitReset, out var resetTime) + ) + { + var resetDateTime = DateTimeOffset.FromUnixTimeSeconds(resetTime); + var delay = (int)(resetDateTime - DateTimeOffset.UtcNow).TotalMilliseconds; + if (delay > 0) + { + return AddPositiveJitter(Math.Min(delay, MaxRetryDelayMs)); + } + } + } + + var exponentialDelay = Math.Min(BaseRetryDelay * (1 << retryAttempt), MaxRetryDelayMs); + return AddSymmetricJitter(exponentialDelay); + } + + private static bool IsRetryableContent(HttpRequestMessage request) + { + return request.Content switch + { + IIsRetryableContent c => c.IsRetryable, + StreamContent => false, + MultipartContent content => !content.Any(c => c is StreamContent), + _ => true, + }; + } + + internal async global::System.Threading.Tasks.Task CreateHttpRequestAsync( + global::SeedApi.Core.BaseRequest request + ) + { + var url = BuildUrl(request); + var httpRequest = new HttpRequestMessage(request.Method, url); + httpRequest.Content = request.CreateContent(); + var mergedHeaders = new Dictionary>(); + await MergeHeadersAsync(mergedHeaders, Options.Headers).ConfigureAwait(false); + MergeAdditionalHeaders(mergedHeaders, Options.AdditionalHeaders); + await MergeHeadersAsync(mergedHeaders, request.Headers).ConfigureAwait(false); + await MergeHeadersAsync(mergedHeaders, request.Options?.Headers).ConfigureAwait(false); + + MergeAdditionalHeaders(mergedHeaders, request.Options?.AdditionalHeaders ?? []); + SetHeaders(httpRequest, mergedHeaders); + return httpRequest; + } + + private static string BuildUrl(global::SeedApi.Core.BaseRequest request) + { + var baseUrl = request.Options?.BaseUrl ?? request.BaseUrl; + var trimmedBaseUrl = baseUrl.TrimEnd('/'); + var trimmedBasePath = request.Path.TrimStart('/'); + var url = $"{trimmedBaseUrl}/{trimmedBasePath}"; + + var queryParameters = GetQueryParameters(request); + if (!queryParameters.Any()) + return url; + + url += "?"; + url = queryParameters.Aggregate( + url, + (current, queryItem) => + { + if ( + queryItem.Value + is global::System.Collections.IEnumerable collection + and not string + ) + { + var items = collection + .Cast() + .Select(value => + $"{Uri.EscapeDataString(queryItem.Key)}={Uri.EscapeDataString(value?.ToString() ?? "")}" + ) + .ToList(); + if (items.Any()) + { + current += string.Join("&", items) + "&"; + } + } + else + { + current += + $"{Uri.EscapeDataString(queryItem.Key)}={Uri.EscapeDataString(queryItem.Value)}&"; + } + + return current; + } + ); + url = url[..^1]; + return url; + } + + private static List> GetQueryParameters( + global::SeedApi.Core.BaseRequest request + ) + { + var result = TransformToKeyValuePairs(request.Query); + if ( + request.Options?.AdditionalQueryParameters is null + || !request.Options.AdditionalQueryParameters.Any() + ) + { + return result; + } + + var additionalKeys = request + .Options.AdditionalQueryParameters.Select(p => p.Key) + .Distinct(); + foreach (var key in additionalKeys) + { + result.RemoveAll(kv => kv.Key == key); + } + + result.AddRange(request.Options.AdditionalQueryParameters); + return result; + } + + private static List> TransformToKeyValuePairs( + Dictionary inputDict + ) + { + var result = new List>(); + foreach (var kvp in inputDict) + { + switch (kvp.Value) + { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; + case string str: + result.Add(new KeyValuePair(kvp.Key, str)); + break; + case IEnumerable strList: + { + foreach (var value in strList) + { + result.Add(new KeyValuePair(kvp.Key, value)); + } + + break; + } + } + } + + return result; + } + + private static async SystemTask MergeHeadersAsync( + Dictionary> mergedHeaders, + Headers? headers + ) + { + if (headers is null) + { + return; + } + + foreach (var header in headers) + { + var value = await header.Value.ResolveAsync().ConfigureAwait(false); + if (value is not null) + { + mergedHeaders[header.Key] = [value]; + } + } + } + + private static void MergeAdditionalHeaders( + Dictionary> mergedHeaders, + IEnumerable>? headers + ) + { + if (headers is null) + { + return; + } + + var usedKeys = new HashSet(); + foreach (var header in headers) + { + if (header.Value is null) + { + mergedHeaders.Remove(header.Key); + usedKeys.Remove(header.Key); + continue; + } + + if (usedKeys.Contains(header.Key)) + { + mergedHeaders[header.Key].Add(header.Value); + } + else + { + mergedHeaders[header.Key] = [header.Value]; + usedKeys.Add(header.Key); + } + } + } + + private void SetHeaders( + HttpRequestMessage httpRequest, + Dictionary> mergedHeaders + ) + { + foreach (var kv in mergedHeaders) + { + foreach (var header in kv.Value) + { + if (header is null) + { + continue; + } + + httpRequest.Headers.TryAddWithoutValidation(kv.Key, header); + } + } + } + + private static (Encoding encoding, string? charset, string mediaType) ParseContentTypeOrDefault( + string? contentType, + Encoding encodingFallback, + string mediaTypeFallback + ) + { + var encoding = encodingFallback; + var mediaType = mediaTypeFallback; + string? charset = null; + if (string.IsNullOrEmpty(contentType)) + { + return (encoding, charset, mediaType); + } + + if (!MediaTypeHeaderValue.TryParse(contentType, out var mediaTypeHeaderValue)) + { + return (encoding, charset, mediaType); + } + + if (!string.IsNullOrEmpty(mediaTypeHeaderValue.CharSet)) + { + charset = mediaTypeHeaderValue.CharSet; + encoding = Encoding.GetEncoding(mediaTypeHeaderValue.CharSet); + } + + if (!string.IsNullOrEmpty(mediaTypeHeaderValue.MediaType)) + { + mediaType = mediaTypeHeaderValue.MediaType; + } + + return (encoding, charset, mediaType); + } + + /// + [Obsolete("Use global::SeedApi.Core.ApiResponse instead.")] + internal record ApiResponse : global::SeedApi.Core.ApiResponse; + + /// + [Obsolete("Use global::SeedApi.Core.BaseRequest instead.")] + internal abstract record BaseApiRequest : global::SeedApi.Core.BaseRequest; + + /// + [Obsolete("Use global::SeedApi.Core.EmptyRequest instead.")] + internal abstract record EmptyApiRequest : global::SeedApi.Core.EmptyRequest; + + /// + [Obsolete("Use global::SeedApi.Core.JsonRequest instead.")] + internal abstract record JsonApiRequest : global::SeedApi.Core.JsonRequest; + + /// + [Obsolete("Use global::SeedApi.Core.MultipartFormRequest instead.")] + internal abstract record MultipartFormRequest : global::SeedApi.Core.MultipartFormRequest; + + /// + [Obsolete("Use global::SeedApi.Core.StreamRequest instead.")] + internal abstract record StreamApiRequest : global::SeedApi.Core.StreamRequest; +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/StreamRequest.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/StreamRequest.cs new file mode 100644 index 000000000000..d675f7946635 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/StreamRequest.cs @@ -0,0 +1,29 @@ +using System.Net.Http; +using System.Net.Http.Headers; + +namespace SeedApi.Core; + +/// +/// The request object to be sent for streaming uploads. +/// +internal record StreamRequest : BaseRequest +{ + internal Stream? Body { get; init; } + + internal override HttpContent? CreateContent() + { + if (Body is null) + { + return null; + } + + var content = new StreamContent(Body) + { + Headers = + { + ContentType = MediaTypeHeaderValue.Parse(ContentType ?? "application/octet-stream"), + }, + }; + return content; + } +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/StringEnum.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/StringEnum.cs new file mode 100644 index 000000000000..ba51a6d1efe0 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/StringEnum.cs @@ -0,0 +1,8 @@ +using System.Text.Json.Serialization; + +namespace SeedApi.Core; + +public interface IStringEnum : IEquatable +{ + public string Value { get; } +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/StringEnumExtensions.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/StringEnumExtensions.cs new file mode 100644 index 000000000000..704cb6836ab8 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/StringEnumExtensions.cs @@ -0,0 +1,6 @@ +namespace SeedApi.Core; + +internal static class StringEnumExtensions +{ + public static string Stringify(this IStringEnum stringEnum) => stringEnum.Value; +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/StringEnumSerializer.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/StringEnumSerializer.cs new file mode 100644 index 000000000000..87b4403561da --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/StringEnumSerializer.cs @@ -0,0 +1,25 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedApi.Core; + +internal class StringEnumSerializer : JsonConverter + where T : IStringEnum +{ + public override T? Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var stringValue = + reader.GetString() + ?? throw new global::System.Exception("The JSON value could not be read as a string."); + return (T?)Activator.CreateInstance(typeToConvert, stringValue); + } + + public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.Value); + } +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/ValueConvert.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/ValueConvert.cs new file mode 100644 index 000000000000..bdbd7967aad3 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Core/ValueConvert.cs @@ -0,0 +1,115 @@ +using global::System.Globalization; + +namespace SeedApi.Core; + +/// +/// Convert values to string for path and query parameters. +/// +public static class ValueConvert +{ + internal static string ToPathParameterString(T value) => ToString(value); + + internal static string ToPathParameterString(bool v) => ToString(v); + + internal static string ToPathParameterString(int v) => ToString(v); + + internal static string ToPathParameterString(long v) => ToString(v); + + internal static string ToPathParameterString(float v) => ToString(v); + + internal static string ToPathParameterString(double v) => ToString(v); + + internal static string ToPathParameterString(decimal v) => ToString(v); + + internal static string ToPathParameterString(short v) => ToString(v); + + internal static string ToPathParameterString(ushort v) => ToString(v); + + internal static string ToPathParameterString(uint v) => ToString(v); + + internal static string ToPathParameterString(ulong v) => ToString(v); + + internal static string ToPathParameterString(string v) => ToString(v); + + internal static string ToPathParameterString(char v) => ToString(v); + + internal static string ToPathParameterString(Guid v) => ToString(v); + + internal static string ToQueryStringValue(T value) => value is null ? "" : ToString(value); + + internal static string ToQueryStringValue(bool v) => ToString(v); + + internal static string ToQueryStringValue(int v) => ToString(v); + + internal static string ToQueryStringValue(long v) => ToString(v); + + internal static string ToQueryStringValue(float v) => ToString(v); + + internal static string ToQueryStringValue(double v) => ToString(v); + + internal static string ToQueryStringValue(decimal v) => ToString(v); + + internal static string ToQueryStringValue(short v) => ToString(v); + + internal static string ToQueryStringValue(ushort v) => ToString(v); + + internal static string ToQueryStringValue(uint v) => ToString(v); + + internal static string ToQueryStringValue(ulong v) => ToString(v); + + internal static string ToQueryStringValue(string v) => v is null ? "" : v; + + internal static string ToQueryStringValue(char v) => ToString(v); + + internal static string ToQueryStringValue(Guid v) => ToString(v); + + internal static string ToString(T value) + { + return value switch + { + null => "null", + string str => str, + true => "true", + false => "false", + int i => ToString(i), + long l => ToString(l), + float f => ToString(f), + double d => ToString(d), + decimal dec => ToString(dec), + short s => ToString(s), + ushort u => ToString(u), + uint u => ToString(u), + ulong u => ToString(u), + char c => ToString(c), + Guid guid => ToString(guid), + Enum e => JsonUtils.Serialize(e).Trim('"'), + _ => JsonUtils.Serialize(value).Trim('"'), + }; + } + + internal static string ToString(bool v) => v ? "true" : "false"; + + internal static string ToString(int v) => v.ToString(CultureInfo.InvariantCulture); + + internal static string ToString(long v) => v.ToString(CultureInfo.InvariantCulture); + + internal static string ToString(float v) => v.ToString(CultureInfo.InvariantCulture); + + internal static string ToString(double v) => v.ToString(CultureInfo.InvariantCulture); + + internal static string ToString(decimal v) => v.ToString(CultureInfo.InvariantCulture); + + internal static string ToString(short v) => v.ToString(CultureInfo.InvariantCulture); + + internal static string ToString(ushort v) => v.ToString(CultureInfo.InvariantCulture); + + internal static string ToString(uint v) => v.ToString(CultureInfo.InvariantCulture); + + internal static string ToString(ulong v) => v.ToString(CultureInfo.InvariantCulture); + + internal static string ToString(char v) => v.ToString(CultureInfo.InvariantCulture); + + internal static string ToString(string v) => v; + + internal static string ToString(Guid v) => v.ToString("D"); +} diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/ISeedApiClient.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/ISeedApiClient.cs new file mode 100644 index 000000000000..a2da81e68a5c --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/ISeedApiClient.cs @@ -0,0 +1,17 @@ +namespace SeedApi; + +public partial interface ISeedApiClient +{ + Task GetFooAsync( + GetFooRequest request, + RequestOptions? options = null, + CancellationToken cancellationToken = default + ); + + Task UpdateFooAsync( + string id, + UpdateFooRequest request, + RequestOptions? options = null, + CancellationToken cancellationToken = default + ); +} diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi/Requests/GetFooRequest.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Requests/GetFooRequest.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi/Requests/GetFooRequest.cs rename to seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Requests/GetFooRequest.cs diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi/Requests/UpdateFooRequest.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Requests/UpdateFooRequest.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi/Requests/UpdateFooRequest.cs rename to seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Requests/UpdateFooRequest.cs diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/SeedApi.Custom.props b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/SeedApi.Custom.props new file mode 100644 index 000000000000..17a84cada530 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/SeedApi.Custom.props @@ -0,0 +1,20 @@ + + + + diff --git a/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/SeedApi.csproj b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/SeedApi.csproj new file mode 100644 index 000000000000..abc717763d35 --- /dev/null +++ b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/SeedApi.csproj @@ -0,0 +1,58 @@ + + + net462;net8.0;net7.0;net6.0;netstandard2.0 + enable + 12 + enable + 0.0.1 + $(Version) + $(Version) + README.md + https://github.com/required-nullable/fern + true + + + + false + + + $(DefineConstants);USE_PORTABLE_DATE_ONLY + true + + + + + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + <_Parameter1>SeedApi.Test + + + + + diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi/SeedApiClient.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/SeedApiClient.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi/SeedApiClient.cs rename to seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/SeedApiClient.cs diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi/Types/Foo.cs b/seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Types/Foo.cs similarity index 100% rename from seed/csharp-sdk/required-nullable/src/SeedApi/Types/Foo.cs rename to seed/csharp-sdk/required-nullable/no-custom-config/src/SeedApi/Types/Foo.cs diff --git a/seed/csharp-sdk/required-nullable/src/SeedApi/Core/JsonConfiguration.cs b/seed/csharp-sdk/required-nullable/src/SeedApi/Core/JsonConfiguration.cs deleted file mode 100644 index 321193691456..000000000000 --- a/seed/csharp-sdk/required-nullable/src/SeedApi/Core/JsonConfiguration.cs +++ /dev/null @@ -1,180 +0,0 @@ -using global::System.Reflection; -using global::System.Text.Json; -using global::System.Text.Json.Nodes; -using global::System.Text.Json.Serialization; -using global::System.Text.Json.Serialization.Metadata; - -namespace SeedApi.Core; - -internal static partial class JsonOptions -{ - internal static readonly JsonSerializerOptions JsonSerializerOptions; - - static JsonOptions() - { - var options = new JsonSerializerOptions - { - Converters = { new DateTimeSerializer(), -#if USE_PORTABLE_DATE_ONLY - new DateOnlyConverter(), -#endif - new OneOfSerializer() }, -#if DEBUG - WriteIndented = true, -#endif - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - TypeInfoResolver = new DefaultJsonTypeInfoResolver - { - Modifiers = - { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, - }, - }, - }; - ConfigureJsonSerializerOptions(options); - JsonSerializerOptions = options; - } - - static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); -} - -internal static class JsonUtils -{ - internal static string Serialize(T obj) => - JsonSerializer.Serialize(obj, JsonOptions.JsonSerializerOptions); - - internal static JsonElement SerializeToElement(T obj) => - JsonSerializer.SerializeToElement(obj, JsonOptions.JsonSerializerOptions); - - internal static JsonDocument SerializeToDocument(T obj) => - JsonSerializer.SerializeToDocument(obj, JsonOptions.JsonSerializerOptions); - - internal static JsonNode? SerializeToNode(T obj) => - JsonSerializer.SerializeToNode(obj, JsonOptions.JsonSerializerOptions); - - internal static byte[] SerializeToUtf8Bytes(T obj) => - JsonSerializer.SerializeToUtf8Bytes(obj, JsonOptions.JsonSerializerOptions); - - internal static string SerializeWithAdditionalProperties( - T obj, - object? additionalProperties = null - ) - { - if (additionalProperties == null) - { - return Serialize(obj); - } - var additionalPropertiesJsonNode = SerializeToNode(additionalProperties); - if (additionalPropertiesJsonNode is not JsonObject additionalPropertiesJsonObject) - { - throw new InvalidOperationException( - "The additional properties must serialize to a JSON object." - ); - } - var jsonNode = SerializeToNode(obj); - if (jsonNode is not JsonObject jsonObject) - { - throw new InvalidOperationException( - "The serialized object must be a JSON object to add properties." - ); - } - MergeJsonObjects(jsonObject, additionalPropertiesJsonObject); - return jsonObject.ToJsonString(JsonOptions.JsonSerializerOptions); - } - - private static void MergeJsonObjects(JsonObject baseObject, JsonObject overrideObject) - { - foreach (var property in overrideObject) - { - if (!baseObject.TryGetPropertyValue(property.Key, out JsonNode? existingValue)) - { - baseObject[property.Key] = - property.Value != null ? JsonNode.Parse(property.Value.ToJsonString()) : null; - continue; - } - if ( - existingValue is JsonObject nestedBaseObject - && property.Value is JsonObject nestedOverrideObject - ) - { - // If both values are objects, recursively merge them. - MergeJsonObjects(nestedBaseObject, nestedOverrideObject); - continue; - } - // Otherwise, the overrideObject takes precedence. - baseObject[property.Key] = - property.Value != null ? JsonNode.Parse(property.Value.ToJsonString()) : null; - } - } - - internal static T Deserialize(string json) => - JsonSerializer.Deserialize(json, JsonOptions.JsonSerializerOptions)!; -} diff --git a/seed/csharp-sdk/reserved-keywords/src/SeedNurseryApi.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/reserved-keywords/src/SeedNurseryApi.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/reserved-keywords/src/SeedNurseryApi.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/reserved-keywords/src/SeedNurseryApi.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/reserved-keywords/src/SeedNurseryApi.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/reserved-keywords/src/SeedNurseryApi.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..f19ed2a43627 --- /dev/null +++ b/seed/csharp-sdk/reserved-keywords/src/SeedNurseryApi.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedNurseryApi.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/reserved-keywords/src/SeedNurseryApi/Core/JsonConfiguration.cs b/seed/csharp-sdk/reserved-keywords/src/SeedNurseryApi/Core/JsonConfiguration.cs index e28f8cb8900a..2c962a7268b9 100644 --- a/seed/csharp-sdk/reserved-keywords/src/SeedNurseryApi/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/reserved-keywords/src/SeedNurseryApi/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/reserved-keywords/src/SeedNurseryApi/Core/NullableAttribute.cs b/seed/csharp-sdk/reserved-keywords/src/SeedNurseryApi/Core/NullableAttribute.cs new file mode 100644 index 000000000000..03535b3a852f --- /dev/null +++ b/seed/csharp-sdk/reserved-keywords/src/SeedNurseryApi/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedNurseryApi.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/reserved-keywords/src/SeedNurseryApi/Core/Optional.cs b/seed/csharp-sdk/reserved-keywords/src/SeedNurseryApi/Core/Optional.cs new file mode 100644 index 000000000000..4c5c811773b1 --- /dev/null +++ b/seed/csharp-sdk/reserved-keywords/src/SeedNurseryApi/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedNurseryApi.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/reserved-keywords/src/SeedNurseryApi/Core/OptionalAttribute.cs b/seed/csharp-sdk/reserved-keywords/src/SeedNurseryApi/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..0cacd351be1f --- /dev/null +++ b/seed/csharp-sdk/reserved-keywords/src/SeedNurseryApi/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedNurseryApi.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/reserved-keywords/src/SeedNurseryApi/Core/RawClient.cs b/seed/csharp-sdk/reserved-keywords/src/SeedNurseryApi/Core/RawClient.cs index 0dd5e1115f58..1d21ea60eaf6 100644 --- a/seed/csharp-sdk/reserved-keywords/src/SeedNurseryApi/Core/RawClient.cs +++ b/seed/csharp-sdk/reserved-keywords/src/SeedNurseryApi/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/response-property/src/SeedResponseProperty.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/response-property/src/SeedResponseProperty.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/response-property/src/SeedResponseProperty.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/response-property/src/SeedResponseProperty.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/response-property/src/SeedResponseProperty.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/response-property/src/SeedResponseProperty.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..e2832aaae344 --- /dev/null +++ b/seed/csharp-sdk/response-property/src/SeedResponseProperty.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedResponseProperty.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/response-property/src/SeedResponseProperty/Core/JsonConfiguration.cs b/seed/csharp-sdk/response-property/src/SeedResponseProperty/Core/JsonConfiguration.cs index 166509bfab0e..dfae13b4524f 100644 --- a/seed/csharp-sdk/response-property/src/SeedResponseProperty/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/response-property/src/SeedResponseProperty/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/response-property/src/SeedResponseProperty/Core/NullableAttribute.cs b/seed/csharp-sdk/response-property/src/SeedResponseProperty/Core/NullableAttribute.cs new file mode 100644 index 000000000000..c16e594d7683 --- /dev/null +++ b/seed/csharp-sdk/response-property/src/SeedResponseProperty/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedResponseProperty.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/response-property/src/SeedResponseProperty/Core/Optional.cs b/seed/csharp-sdk/response-property/src/SeedResponseProperty/Core/Optional.cs new file mode 100644 index 000000000000..e49f61f3722f --- /dev/null +++ b/seed/csharp-sdk/response-property/src/SeedResponseProperty/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedResponseProperty.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/response-property/src/SeedResponseProperty/Core/OptionalAttribute.cs b/seed/csharp-sdk/response-property/src/SeedResponseProperty/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..dc06aa40d5d7 --- /dev/null +++ b/seed/csharp-sdk/response-property/src/SeedResponseProperty/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedResponseProperty.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/response-property/src/SeedResponseProperty/Core/RawClient.cs b/seed/csharp-sdk/response-property/src/SeedResponseProperty/Core/RawClient.cs index ccda2d2e618d..007a41de3835 100644 --- a/seed/csharp-sdk/response-property/src/SeedResponseProperty/Core/RawClient.cs +++ b/seed/csharp-sdk/response-property/src/SeedResponseProperty/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/seed.yml b/seed/csharp-sdk/seed.yml index e5b3eb8720bd..3732a40e8e90 100644 --- a/seed/csharp-sdk/seed.yml +++ b/seed/csharp-sdk/seed.yml @@ -255,6 +255,24 @@ fixtures: csharp-xml-entities: - customConfig: null outputFolder: no-custom-config + nullable: + - customConfig: null + outputFolder: no-custom-config + - customConfig: + experimental-explicit-nullable-optional: true + outputFolder: explicit-nullable-optional + nullable-optional: + - customConfig: null + outputFolder: no-custom-config + - customConfig: + experimental-explicit-nullable-optional: true + outputFolder: explicit-nullable-optional + required-nullable: + - customConfig: null + outputFolder: no-custom-config + - customConfig: + experimental-explicit-nullable-optional: true + outputFolder: explicit-nullable-optional allowedFailures: - any-auth - endpoint-security-auth diff --git a/seed/csharp-sdk/server-sent-event-examples/src/SeedServerSentEvents.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/server-sent-event-examples/src/SeedServerSentEvents.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/server-sent-event-examples/src/SeedServerSentEvents.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/server-sent-event-examples/src/SeedServerSentEvents.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/server-sent-event-examples/src/SeedServerSentEvents.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/server-sent-event-examples/src/SeedServerSentEvents.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..47e55d7d9398 --- /dev/null +++ b/seed/csharp-sdk/server-sent-event-examples/src/SeedServerSentEvents.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedServerSentEvents.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/server-sent-event-examples/src/SeedServerSentEvents/Core/JsonConfiguration.cs b/seed/csharp-sdk/server-sent-event-examples/src/SeedServerSentEvents/Core/JsonConfiguration.cs index 24e9c7867358..c7ed0fc3e91b 100644 --- a/seed/csharp-sdk/server-sent-event-examples/src/SeedServerSentEvents/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/server-sent-event-examples/src/SeedServerSentEvents/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/server-sent-event-examples/src/SeedServerSentEvents/Core/NullableAttribute.cs b/seed/csharp-sdk/server-sent-event-examples/src/SeedServerSentEvents/Core/NullableAttribute.cs new file mode 100644 index 000000000000..2839b7736089 --- /dev/null +++ b/seed/csharp-sdk/server-sent-event-examples/src/SeedServerSentEvents/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedServerSentEvents.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/server-sent-event-examples/src/SeedServerSentEvents/Core/Optional.cs b/seed/csharp-sdk/server-sent-event-examples/src/SeedServerSentEvents/Core/Optional.cs new file mode 100644 index 000000000000..ad197cabeb1c --- /dev/null +++ b/seed/csharp-sdk/server-sent-event-examples/src/SeedServerSentEvents/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedServerSentEvents.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/server-sent-event-examples/src/SeedServerSentEvents/Core/OptionalAttribute.cs b/seed/csharp-sdk/server-sent-event-examples/src/SeedServerSentEvents/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..6a6aaa43d293 --- /dev/null +++ b/seed/csharp-sdk/server-sent-event-examples/src/SeedServerSentEvents/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedServerSentEvents.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/server-sent-event-examples/src/SeedServerSentEvents/Core/RawClient.cs b/seed/csharp-sdk/server-sent-event-examples/src/SeedServerSentEvents/Core/RawClient.cs index 3124a4d33c15..efc27b9dd0d0 100644 --- a/seed/csharp-sdk/server-sent-event-examples/src/SeedServerSentEvents/Core/RawClient.cs +++ b/seed/csharp-sdk/server-sent-event-examples/src/SeedServerSentEvents/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/server-sent-events/src/SeedServerSentEvents.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/server-sent-events/src/SeedServerSentEvents.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/server-sent-events/src/SeedServerSentEvents.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/server-sent-events/src/SeedServerSentEvents.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/server-sent-events/src/SeedServerSentEvents.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/server-sent-events/src/SeedServerSentEvents.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..47e55d7d9398 --- /dev/null +++ b/seed/csharp-sdk/server-sent-events/src/SeedServerSentEvents.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedServerSentEvents.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/server-sent-events/src/SeedServerSentEvents/Core/JsonConfiguration.cs b/seed/csharp-sdk/server-sent-events/src/SeedServerSentEvents/Core/JsonConfiguration.cs index 24e9c7867358..c7ed0fc3e91b 100644 --- a/seed/csharp-sdk/server-sent-events/src/SeedServerSentEvents/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/server-sent-events/src/SeedServerSentEvents/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/server-sent-events/src/SeedServerSentEvents/Core/NullableAttribute.cs b/seed/csharp-sdk/server-sent-events/src/SeedServerSentEvents/Core/NullableAttribute.cs new file mode 100644 index 000000000000..2839b7736089 --- /dev/null +++ b/seed/csharp-sdk/server-sent-events/src/SeedServerSentEvents/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedServerSentEvents.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/server-sent-events/src/SeedServerSentEvents/Core/Optional.cs b/seed/csharp-sdk/server-sent-events/src/SeedServerSentEvents/Core/Optional.cs new file mode 100644 index 000000000000..ad197cabeb1c --- /dev/null +++ b/seed/csharp-sdk/server-sent-events/src/SeedServerSentEvents/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedServerSentEvents.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/server-sent-events/src/SeedServerSentEvents/Core/OptionalAttribute.cs b/seed/csharp-sdk/server-sent-events/src/SeedServerSentEvents/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..6a6aaa43d293 --- /dev/null +++ b/seed/csharp-sdk/server-sent-events/src/SeedServerSentEvents/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedServerSentEvents.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/server-sent-events/src/SeedServerSentEvents/Core/RawClient.cs b/seed/csharp-sdk/server-sent-events/src/SeedServerSentEvents/Core/RawClient.cs index 3124a4d33c15..efc27b9dd0d0 100644 --- a/seed/csharp-sdk/server-sent-events/src/SeedServerSentEvents/Core/RawClient.cs +++ b/seed/csharp-sdk/server-sent-events/src/SeedServerSentEvents/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/simple-api/custom-output-path-object/lib/SeedApi/SeedSimpleApi/Core/JsonConfiguration.cs b/seed/csharp-sdk/simple-api/custom-output-path-object/lib/SeedApi/SeedSimpleApi/Core/JsonConfiguration.cs index 3d82439a47ff..d0997193d93c 100644 --- a/seed/csharp-sdk/simple-api/custom-output-path-object/lib/SeedApi/SeedSimpleApi/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/simple-api/custom-output-path-object/lib/SeedApi/SeedSimpleApi/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/simple-api/custom-output-path-object/lib/SeedApi/SeedSimpleApi/Core/NullableAttribute.cs b/seed/csharp-sdk/simple-api/custom-output-path-object/lib/SeedApi/SeedSimpleApi/Core/NullableAttribute.cs new file mode 100644 index 000000000000..84c1a3877d71 --- /dev/null +++ b/seed/csharp-sdk/simple-api/custom-output-path-object/lib/SeedApi/SeedSimpleApi/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedSimpleApi.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/simple-api/custom-output-path-object/lib/SeedApi/SeedSimpleApi/Core/Optional.cs b/seed/csharp-sdk/simple-api/custom-output-path-object/lib/SeedApi/SeedSimpleApi/Core/Optional.cs new file mode 100644 index 000000000000..ed8993f87aeb --- /dev/null +++ b/seed/csharp-sdk/simple-api/custom-output-path-object/lib/SeedApi/SeedSimpleApi/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedSimpleApi.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/simple-api/custom-output-path-object/lib/SeedApi/SeedSimpleApi/Core/OptionalAttribute.cs b/seed/csharp-sdk/simple-api/custom-output-path-object/lib/SeedApi/SeedSimpleApi/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..5ff7f0d2d501 --- /dev/null +++ b/seed/csharp-sdk/simple-api/custom-output-path-object/lib/SeedApi/SeedSimpleApi/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedSimpleApi.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/simple-api/custom-output-path-object/lib/SeedApi/SeedSimpleApi/Core/RawClient.cs b/seed/csharp-sdk/simple-api/custom-output-path-object/lib/SeedApi/SeedSimpleApi/Core/RawClient.cs index 60c56122fc87..1b1e8e8d87cf 100644 --- a/seed/csharp-sdk/simple-api/custom-output-path-object/lib/SeedApi/SeedSimpleApi/Core/RawClient.cs +++ b/seed/csharp-sdk/simple-api/custom-output-path-object/lib/SeedApi/SeedSimpleApi/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/simple-api/custom-output-path-object/test/SeedApi.Test/SeedSimpleApi.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/simple-api/custom-output-path-object/test/SeedApi.Test/SeedSimpleApi.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/simple-api/custom-output-path-object/test/SeedApi.Test/SeedSimpleApi.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/simple-api/custom-output-path-object/test/SeedApi.Test/SeedSimpleApi.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/simple-api/custom-output-path-object/test/SeedApi.Test/SeedSimpleApi.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/simple-api/custom-output-path-object/test/SeedApi.Test/SeedSimpleApi.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..b6697bf325b6 --- /dev/null +++ b/seed/csharp-sdk/simple-api/custom-output-path-object/test/SeedApi.Test/SeedSimpleApi.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedSimpleApi.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/simple-api/custom-output-path/custom-src/SeedSimpleApi.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/simple-api/custom-output-path/custom-src/SeedSimpleApi.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/simple-api/custom-output-path/custom-src/SeedSimpleApi.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/simple-api/custom-output-path/custom-src/SeedSimpleApi.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/simple-api/custom-output-path/custom-src/SeedSimpleApi.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/simple-api/custom-output-path/custom-src/SeedSimpleApi.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..b6697bf325b6 --- /dev/null +++ b/seed/csharp-sdk/simple-api/custom-output-path/custom-src/SeedSimpleApi.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedSimpleApi.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/simple-api/custom-output-path/custom-src/SeedSimpleApi/Core/JsonConfiguration.cs b/seed/csharp-sdk/simple-api/custom-output-path/custom-src/SeedSimpleApi/Core/JsonConfiguration.cs index 3d82439a47ff..d0997193d93c 100644 --- a/seed/csharp-sdk/simple-api/custom-output-path/custom-src/SeedSimpleApi/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/simple-api/custom-output-path/custom-src/SeedSimpleApi/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/simple-api/custom-output-path/custom-src/SeedSimpleApi/Core/NullableAttribute.cs b/seed/csharp-sdk/simple-api/custom-output-path/custom-src/SeedSimpleApi/Core/NullableAttribute.cs new file mode 100644 index 000000000000..84c1a3877d71 --- /dev/null +++ b/seed/csharp-sdk/simple-api/custom-output-path/custom-src/SeedSimpleApi/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedSimpleApi.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/simple-api/custom-output-path/custom-src/SeedSimpleApi/Core/Optional.cs b/seed/csharp-sdk/simple-api/custom-output-path/custom-src/SeedSimpleApi/Core/Optional.cs new file mode 100644 index 000000000000..ed8993f87aeb --- /dev/null +++ b/seed/csharp-sdk/simple-api/custom-output-path/custom-src/SeedSimpleApi/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedSimpleApi.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/simple-api/custom-output-path/custom-src/SeedSimpleApi/Core/OptionalAttribute.cs b/seed/csharp-sdk/simple-api/custom-output-path/custom-src/SeedSimpleApi/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..5ff7f0d2d501 --- /dev/null +++ b/seed/csharp-sdk/simple-api/custom-output-path/custom-src/SeedSimpleApi/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedSimpleApi.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/simple-api/custom-output-path/custom-src/SeedSimpleApi/Core/RawClient.cs b/seed/csharp-sdk/simple-api/custom-output-path/custom-src/SeedSimpleApi/Core/RawClient.cs index 60c56122fc87..1b1e8e8d87cf 100644 --- a/seed/csharp-sdk/simple-api/custom-output-path/custom-src/SeedSimpleApi/Core/RawClient.cs +++ b/seed/csharp-sdk/simple-api/custom-output-path/custom-src/SeedSimpleApi/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/simple-api/no-custom-config/src/SeedSimpleApi.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/simple-api/no-custom-config/src/SeedSimpleApi.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/simple-api/no-custom-config/src/SeedSimpleApi.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/simple-api/no-custom-config/src/SeedSimpleApi.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/simple-api/no-custom-config/src/SeedSimpleApi.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/simple-api/no-custom-config/src/SeedSimpleApi.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..b6697bf325b6 --- /dev/null +++ b/seed/csharp-sdk/simple-api/no-custom-config/src/SeedSimpleApi.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedSimpleApi.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/simple-api/no-custom-config/src/SeedSimpleApi/Core/JsonConfiguration.cs b/seed/csharp-sdk/simple-api/no-custom-config/src/SeedSimpleApi/Core/JsonConfiguration.cs index 3d82439a47ff..d0997193d93c 100644 --- a/seed/csharp-sdk/simple-api/no-custom-config/src/SeedSimpleApi/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/simple-api/no-custom-config/src/SeedSimpleApi/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/simple-api/no-custom-config/src/SeedSimpleApi/Core/NullableAttribute.cs b/seed/csharp-sdk/simple-api/no-custom-config/src/SeedSimpleApi/Core/NullableAttribute.cs new file mode 100644 index 000000000000..84c1a3877d71 --- /dev/null +++ b/seed/csharp-sdk/simple-api/no-custom-config/src/SeedSimpleApi/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedSimpleApi.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/simple-api/no-custom-config/src/SeedSimpleApi/Core/Optional.cs b/seed/csharp-sdk/simple-api/no-custom-config/src/SeedSimpleApi/Core/Optional.cs new file mode 100644 index 000000000000..ed8993f87aeb --- /dev/null +++ b/seed/csharp-sdk/simple-api/no-custom-config/src/SeedSimpleApi/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedSimpleApi.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/simple-api/no-custom-config/src/SeedSimpleApi/Core/OptionalAttribute.cs b/seed/csharp-sdk/simple-api/no-custom-config/src/SeedSimpleApi/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..5ff7f0d2d501 --- /dev/null +++ b/seed/csharp-sdk/simple-api/no-custom-config/src/SeedSimpleApi/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedSimpleApi.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/simple-api/no-custom-config/src/SeedSimpleApi/Core/RawClient.cs b/seed/csharp-sdk/simple-api/no-custom-config/src/SeedSimpleApi/Core/RawClient.cs index 60c56122fc87..1b1e8e8d87cf 100644 --- a/seed/csharp-sdk/simple-api/no-custom-config/src/SeedSimpleApi/Core/RawClient.cs +++ b/seed/csharp-sdk/simple-api/no-custom-config/src/SeedSimpleApi/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/simple-fhir/src/SeedApi.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/simple-fhir/src/SeedApi.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/simple-fhir/src/SeedApi.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/simple-fhir/src/SeedApi.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/simple-fhir/src/SeedApi.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/simple-fhir/src/SeedApi.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..dc732084c15f --- /dev/null +++ b/seed/csharp-sdk/simple-fhir/src/SeedApi.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedApi.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/simple-fhir/src/SeedApi/Core/JsonConfiguration.cs b/seed/csharp-sdk/simple-fhir/src/SeedApi/Core/JsonConfiguration.cs index 321193691456..df6b2c46945d 100644 --- a/seed/csharp-sdk/simple-fhir/src/SeedApi/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/simple-fhir/src/SeedApi/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/simple-fhir/src/SeedApi/Core/NullableAttribute.cs b/seed/csharp-sdk/simple-fhir/src/SeedApi/Core/NullableAttribute.cs new file mode 100644 index 000000000000..8fca30535a2a --- /dev/null +++ b/seed/csharp-sdk/simple-fhir/src/SeedApi/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedApi.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/simple-fhir/src/SeedApi/Core/Optional.cs b/seed/csharp-sdk/simple-fhir/src/SeedApi/Core/Optional.cs new file mode 100644 index 000000000000..4a9691feca1f --- /dev/null +++ b/seed/csharp-sdk/simple-fhir/src/SeedApi/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedApi.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/simple-fhir/src/SeedApi/Core/OptionalAttribute.cs b/seed/csharp-sdk/simple-fhir/src/SeedApi/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..b2bc05f7244f --- /dev/null +++ b/seed/csharp-sdk/simple-fhir/src/SeedApi/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedApi.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/simple-fhir/src/SeedApi/Core/RawClient.cs b/seed/csharp-sdk/simple-fhir/src/SeedApi/Core/RawClient.cs index 72e34bbc0e89..b60968e4eb03 100644 --- a/seed/csharp-sdk/simple-fhir/src/SeedApi/Core/RawClient.cs +++ b/seed/csharp-sdk/simple-fhir/src/SeedApi/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/single-url-environment-default/src/SeedSingleUrlEnvironmentDefault.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/single-url-environment-default/src/SeedSingleUrlEnvironmentDefault.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/single-url-environment-default/src/SeedSingleUrlEnvironmentDefault.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/single-url-environment-default/src/SeedSingleUrlEnvironmentDefault.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/single-url-environment-default/src/SeedSingleUrlEnvironmentDefault.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/single-url-environment-default/src/SeedSingleUrlEnvironmentDefault.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..49fc17f81922 --- /dev/null +++ b/seed/csharp-sdk/single-url-environment-default/src/SeedSingleUrlEnvironmentDefault.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedSingleUrlEnvironmentDefault.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/single-url-environment-default/src/SeedSingleUrlEnvironmentDefault/Core/JsonConfiguration.cs b/seed/csharp-sdk/single-url-environment-default/src/SeedSingleUrlEnvironmentDefault/Core/JsonConfiguration.cs index 7ad99be5fe48..a460ff302f6f 100644 --- a/seed/csharp-sdk/single-url-environment-default/src/SeedSingleUrlEnvironmentDefault/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/single-url-environment-default/src/SeedSingleUrlEnvironmentDefault/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/single-url-environment-default/src/SeedSingleUrlEnvironmentDefault/Core/NullableAttribute.cs b/seed/csharp-sdk/single-url-environment-default/src/SeedSingleUrlEnvironmentDefault/Core/NullableAttribute.cs new file mode 100644 index 000000000000..a5a93f45e4b0 --- /dev/null +++ b/seed/csharp-sdk/single-url-environment-default/src/SeedSingleUrlEnvironmentDefault/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedSingleUrlEnvironmentDefault.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/single-url-environment-default/src/SeedSingleUrlEnvironmentDefault/Core/Optional.cs b/seed/csharp-sdk/single-url-environment-default/src/SeedSingleUrlEnvironmentDefault/Core/Optional.cs new file mode 100644 index 000000000000..fbdd3e013d18 --- /dev/null +++ b/seed/csharp-sdk/single-url-environment-default/src/SeedSingleUrlEnvironmentDefault/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedSingleUrlEnvironmentDefault.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/single-url-environment-default/src/SeedSingleUrlEnvironmentDefault/Core/OptionalAttribute.cs b/seed/csharp-sdk/single-url-environment-default/src/SeedSingleUrlEnvironmentDefault/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..b6fef8af43f7 --- /dev/null +++ b/seed/csharp-sdk/single-url-environment-default/src/SeedSingleUrlEnvironmentDefault/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedSingleUrlEnvironmentDefault.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/single-url-environment-default/src/SeedSingleUrlEnvironmentDefault/Core/RawClient.cs b/seed/csharp-sdk/single-url-environment-default/src/SeedSingleUrlEnvironmentDefault/Core/RawClient.cs index fb3f7edb4720..3d0a42f5800a 100644 --- a/seed/csharp-sdk/single-url-environment-default/src/SeedSingleUrlEnvironmentDefault/Core/RawClient.cs +++ b/seed/csharp-sdk/single-url-environment-default/src/SeedSingleUrlEnvironmentDefault/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/single-url-environment-no-default/src/SeedSingleUrlEnvironmentNoDefault.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/single-url-environment-no-default/src/SeedSingleUrlEnvironmentNoDefault.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/single-url-environment-no-default/src/SeedSingleUrlEnvironmentNoDefault.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/single-url-environment-no-default/src/SeedSingleUrlEnvironmentNoDefault.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/single-url-environment-no-default/src/SeedSingleUrlEnvironmentNoDefault.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/single-url-environment-no-default/src/SeedSingleUrlEnvironmentNoDefault.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..7a246c0ac9e4 --- /dev/null +++ b/seed/csharp-sdk/single-url-environment-no-default/src/SeedSingleUrlEnvironmentNoDefault.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedSingleUrlEnvironmentNoDefault.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/single-url-environment-no-default/src/SeedSingleUrlEnvironmentNoDefault/Core/JsonConfiguration.cs b/seed/csharp-sdk/single-url-environment-no-default/src/SeedSingleUrlEnvironmentNoDefault/Core/JsonConfiguration.cs index e7eaa2de2b7f..dad2a9aa3de8 100644 --- a/seed/csharp-sdk/single-url-environment-no-default/src/SeedSingleUrlEnvironmentNoDefault/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/single-url-environment-no-default/src/SeedSingleUrlEnvironmentNoDefault/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/single-url-environment-no-default/src/SeedSingleUrlEnvironmentNoDefault/Core/NullableAttribute.cs b/seed/csharp-sdk/single-url-environment-no-default/src/SeedSingleUrlEnvironmentNoDefault/Core/NullableAttribute.cs new file mode 100644 index 000000000000..37c8f0114e51 --- /dev/null +++ b/seed/csharp-sdk/single-url-environment-no-default/src/SeedSingleUrlEnvironmentNoDefault/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedSingleUrlEnvironmentNoDefault.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/single-url-environment-no-default/src/SeedSingleUrlEnvironmentNoDefault/Core/Optional.cs b/seed/csharp-sdk/single-url-environment-no-default/src/SeedSingleUrlEnvironmentNoDefault/Core/Optional.cs new file mode 100644 index 000000000000..ef096c959406 --- /dev/null +++ b/seed/csharp-sdk/single-url-environment-no-default/src/SeedSingleUrlEnvironmentNoDefault/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedSingleUrlEnvironmentNoDefault.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/single-url-environment-no-default/src/SeedSingleUrlEnvironmentNoDefault/Core/OptionalAttribute.cs b/seed/csharp-sdk/single-url-environment-no-default/src/SeedSingleUrlEnvironmentNoDefault/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..2ee1e2badd78 --- /dev/null +++ b/seed/csharp-sdk/single-url-environment-no-default/src/SeedSingleUrlEnvironmentNoDefault/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedSingleUrlEnvironmentNoDefault.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/single-url-environment-no-default/src/SeedSingleUrlEnvironmentNoDefault/Core/RawClient.cs b/seed/csharp-sdk/single-url-environment-no-default/src/SeedSingleUrlEnvironmentNoDefault/Core/RawClient.cs index 96810c809020..71dc0d7b305e 100644 --- a/seed/csharp-sdk/single-url-environment-no-default/src/SeedSingleUrlEnvironmentNoDefault/Core/RawClient.cs +++ b/seed/csharp-sdk/single-url-environment-no-default/src/SeedSingleUrlEnvironmentNoDefault/Core/RawClient.cs @@ -357,6 +357,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/streaming-parameter/src/SeedStreaming.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/streaming-parameter/src/SeedStreaming.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/streaming-parameter/src/SeedStreaming.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/streaming-parameter/src/SeedStreaming.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/streaming-parameter/src/SeedStreaming.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/streaming-parameter/src/SeedStreaming.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..f750ddc0cf24 --- /dev/null +++ b/seed/csharp-sdk/streaming-parameter/src/SeedStreaming.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedStreaming.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/streaming-parameter/src/SeedStreaming/Core/JsonConfiguration.cs b/seed/csharp-sdk/streaming-parameter/src/SeedStreaming/Core/JsonConfiguration.cs index cbf11410c34a..0cfac6a50cde 100644 --- a/seed/csharp-sdk/streaming-parameter/src/SeedStreaming/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/streaming-parameter/src/SeedStreaming/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/streaming-parameter/src/SeedStreaming/Core/NullableAttribute.cs b/seed/csharp-sdk/streaming-parameter/src/SeedStreaming/Core/NullableAttribute.cs new file mode 100644 index 000000000000..2eceed79f852 --- /dev/null +++ b/seed/csharp-sdk/streaming-parameter/src/SeedStreaming/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedStreaming.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/streaming-parameter/src/SeedStreaming/Core/Optional.cs b/seed/csharp-sdk/streaming-parameter/src/SeedStreaming/Core/Optional.cs new file mode 100644 index 000000000000..68fe2cc8c207 --- /dev/null +++ b/seed/csharp-sdk/streaming-parameter/src/SeedStreaming/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedStreaming.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/streaming-parameter/src/SeedStreaming/Core/OptionalAttribute.cs b/seed/csharp-sdk/streaming-parameter/src/SeedStreaming/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..d77eec899056 --- /dev/null +++ b/seed/csharp-sdk/streaming-parameter/src/SeedStreaming/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedStreaming.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/streaming-parameter/src/SeedStreaming/Core/RawClient.cs b/seed/csharp-sdk/streaming-parameter/src/SeedStreaming/Core/RawClient.cs index 6d227e8248fa..213417be95cd 100644 --- a/seed/csharp-sdk/streaming-parameter/src/SeedStreaming/Core/RawClient.cs +++ b/seed/csharp-sdk/streaming-parameter/src/SeedStreaming/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/streaming/src/SeedStreaming.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/streaming/src/SeedStreaming.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/streaming/src/SeedStreaming.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/streaming/src/SeedStreaming.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/streaming/src/SeedStreaming.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/streaming/src/SeedStreaming.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..f750ddc0cf24 --- /dev/null +++ b/seed/csharp-sdk/streaming/src/SeedStreaming.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedStreaming.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/streaming/src/SeedStreaming/Core/JsonConfiguration.cs b/seed/csharp-sdk/streaming/src/SeedStreaming/Core/JsonConfiguration.cs index c87d607e8519..79e335799182 100644 --- a/seed/csharp-sdk/streaming/src/SeedStreaming/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/streaming/src/SeedStreaming/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/streaming/src/SeedStreaming/Core/NullableAttribute.cs b/seed/csharp-sdk/streaming/src/SeedStreaming/Core/NullableAttribute.cs new file mode 100644 index 000000000000..2eceed79f852 --- /dev/null +++ b/seed/csharp-sdk/streaming/src/SeedStreaming/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedStreaming.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/streaming/src/SeedStreaming/Core/Optional.cs b/seed/csharp-sdk/streaming/src/SeedStreaming/Core/Optional.cs new file mode 100644 index 000000000000..68fe2cc8c207 --- /dev/null +++ b/seed/csharp-sdk/streaming/src/SeedStreaming/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedStreaming.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/streaming/src/SeedStreaming/Core/OptionalAttribute.cs b/seed/csharp-sdk/streaming/src/SeedStreaming/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..d77eec899056 --- /dev/null +++ b/seed/csharp-sdk/streaming/src/SeedStreaming/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedStreaming.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/streaming/src/SeedStreaming/Core/RawClient.cs b/seed/csharp-sdk/streaming/src/SeedStreaming/Core/RawClient.cs index 6d227e8248fa..213417be95cd 100644 --- a/seed/csharp-sdk/streaming/src/SeedStreaming/Core/RawClient.cs +++ b/seed/csharp-sdk/streaming/src/SeedStreaming/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/trace/src/SeedTrace.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/trace/src/SeedTrace.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/trace/src/SeedTrace.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/trace/src/SeedTrace.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/trace/src/SeedTrace.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/trace/src/SeedTrace.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..b083dcbd8179 --- /dev/null +++ b/seed/csharp-sdk/trace/src/SeedTrace.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedTrace.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/trace/src/SeedTrace/Core/JsonConfiguration.cs b/seed/csharp-sdk/trace/src/SeedTrace/Core/JsonConfiguration.cs index 2d094014b8dd..83fc22ce31be 100644 --- a/seed/csharp-sdk/trace/src/SeedTrace/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/trace/src/SeedTrace/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/trace/src/SeedTrace/Core/NullableAttribute.cs b/seed/csharp-sdk/trace/src/SeedTrace/Core/NullableAttribute.cs new file mode 100644 index 000000000000..8c7a3a44eb6f --- /dev/null +++ b/seed/csharp-sdk/trace/src/SeedTrace/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedTrace.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/trace/src/SeedTrace/Core/Optional.cs b/seed/csharp-sdk/trace/src/SeedTrace/Core/Optional.cs new file mode 100644 index 000000000000..eec96e6f8ef0 --- /dev/null +++ b/seed/csharp-sdk/trace/src/SeedTrace/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedTrace.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/trace/src/SeedTrace/Core/OptionalAttribute.cs b/seed/csharp-sdk/trace/src/SeedTrace/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..87dacbbc4766 --- /dev/null +++ b/seed/csharp-sdk/trace/src/SeedTrace/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedTrace.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/trace/src/SeedTrace/Core/RawClient.cs b/seed/csharp-sdk/trace/src/SeedTrace/Core/RawClient.cs index 68aa31d96c0b..25c5d795e642 100644 --- a/seed/csharp-sdk/trace/src/SeedTrace/Core/RawClient.cs +++ b/seed/csharp-sdk/trace/src/SeedTrace/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/undiscriminated-union-with-response-property/src/SeedUndiscriminatedUnionWithResponseProperty.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/undiscriminated-union-with-response-property/src/SeedUndiscriminatedUnionWithResponseProperty.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/undiscriminated-union-with-response-property/src/SeedUndiscriminatedUnionWithResponseProperty.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/undiscriminated-union-with-response-property/src/SeedUndiscriminatedUnionWithResponseProperty.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/undiscriminated-union-with-response-property/src/SeedUndiscriminatedUnionWithResponseProperty.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/undiscriminated-union-with-response-property/src/SeedUndiscriminatedUnionWithResponseProperty.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..5a79e70452d8 --- /dev/null +++ b/seed/csharp-sdk/undiscriminated-union-with-response-property/src/SeedUndiscriminatedUnionWithResponseProperty.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedUndiscriminatedUnionWithResponseProperty.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/undiscriminated-union-with-response-property/src/SeedUndiscriminatedUnionWithResponseProperty/Core/JsonConfiguration.cs b/seed/csharp-sdk/undiscriminated-union-with-response-property/src/SeedUndiscriminatedUnionWithResponseProperty/Core/JsonConfiguration.cs index 452359b6ca30..8f6623a320ec 100644 --- a/seed/csharp-sdk/undiscriminated-union-with-response-property/src/SeedUndiscriminatedUnionWithResponseProperty/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/undiscriminated-union-with-response-property/src/SeedUndiscriminatedUnionWithResponseProperty/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/undiscriminated-union-with-response-property/src/SeedUndiscriminatedUnionWithResponseProperty/Core/NullableAttribute.cs b/seed/csharp-sdk/undiscriminated-union-with-response-property/src/SeedUndiscriminatedUnionWithResponseProperty/Core/NullableAttribute.cs new file mode 100644 index 000000000000..c0800e3b010a --- /dev/null +++ b/seed/csharp-sdk/undiscriminated-union-with-response-property/src/SeedUndiscriminatedUnionWithResponseProperty/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedUndiscriminatedUnionWithResponseProperty.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/undiscriminated-union-with-response-property/src/SeedUndiscriminatedUnionWithResponseProperty/Core/Optional.cs b/seed/csharp-sdk/undiscriminated-union-with-response-property/src/SeedUndiscriminatedUnionWithResponseProperty/Core/Optional.cs new file mode 100644 index 000000000000..54cbeb3ac021 --- /dev/null +++ b/seed/csharp-sdk/undiscriminated-union-with-response-property/src/SeedUndiscriminatedUnionWithResponseProperty/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedUndiscriminatedUnionWithResponseProperty.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/undiscriminated-union-with-response-property/src/SeedUndiscriminatedUnionWithResponseProperty/Core/OptionalAttribute.cs b/seed/csharp-sdk/undiscriminated-union-with-response-property/src/SeedUndiscriminatedUnionWithResponseProperty/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..623de576daa6 --- /dev/null +++ b/seed/csharp-sdk/undiscriminated-union-with-response-property/src/SeedUndiscriminatedUnionWithResponseProperty/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedUndiscriminatedUnionWithResponseProperty.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/undiscriminated-union-with-response-property/src/SeedUndiscriminatedUnionWithResponseProperty/Core/RawClient.cs b/seed/csharp-sdk/undiscriminated-union-with-response-property/src/SeedUndiscriminatedUnionWithResponseProperty/Core/RawClient.cs index 50cb4704575f..f977e56ae7c8 100644 --- a/seed/csharp-sdk/undiscriminated-union-with-response-property/src/SeedUndiscriminatedUnionWithResponseProperty/Core/RawClient.cs +++ b/seed/csharp-sdk/undiscriminated-union-with-response-property/src/SeedUndiscriminatedUnionWithResponseProperty/Core/RawClient.cs @@ -357,6 +357,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/undiscriminated-unions/src/SeedUndiscriminatedUnions.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/undiscriminated-unions/src/SeedUndiscriminatedUnions.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/undiscriminated-unions/src/SeedUndiscriminatedUnions.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/undiscriminated-unions/src/SeedUndiscriminatedUnions.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/undiscriminated-unions/src/SeedUndiscriminatedUnions.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/undiscriminated-unions/src/SeedUndiscriminatedUnions.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..5623ee879c98 --- /dev/null +++ b/seed/csharp-sdk/undiscriminated-unions/src/SeedUndiscriminatedUnions.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedUndiscriminatedUnions.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/undiscriminated-unions/src/SeedUndiscriminatedUnions/Core/JsonConfiguration.cs b/seed/csharp-sdk/undiscriminated-unions/src/SeedUndiscriminatedUnions/Core/JsonConfiguration.cs index 66ff59a4acdc..448e35c58d42 100644 --- a/seed/csharp-sdk/undiscriminated-unions/src/SeedUndiscriminatedUnions/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/undiscriminated-unions/src/SeedUndiscriminatedUnions/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/undiscriminated-unions/src/SeedUndiscriminatedUnions/Core/NullableAttribute.cs b/seed/csharp-sdk/undiscriminated-unions/src/SeedUndiscriminatedUnions/Core/NullableAttribute.cs new file mode 100644 index 000000000000..fc104896cec5 --- /dev/null +++ b/seed/csharp-sdk/undiscriminated-unions/src/SeedUndiscriminatedUnions/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedUndiscriminatedUnions.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/undiscriminated-unions/src/SeedUndiscriminatedUnions/Core/Optional.cs b/seed/csharp-sdk/undiscriminated-unions/src/SeedUndiscriminatedUnions/Core/Optional.cs new file mode 100644 index 000000000000..ebfee7ec935d --- /dev/null +++ b/seed/csharp-sdk/undiscriminated-unions/src/SeedUndiscriminatedUnions/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedUndiscriminatedUnions.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/undiscriminated-unions/src/SeedUndiscriminatedUnions/Core/OptionalAttribute.cs b/seed/csharp-sdk/undiscriminated-unions/src/SeedUndiscriminatedUnions/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..f24bcefb8a94 --- /dev/null +++ b/seed/csharp-sdk/undiscriminated-unions/src/SeedUndiscriminatedUnions/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedUndiscriminatedUnions.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/undiscriminated-unions/src/SeedUndiscriminatedUnions/Core/RawClient.cs b/seed/csharp-sdk/undiscriminated-unions/src/SeedUndiscriminatedUnions/Core/RawClient.cs index 304d749385ec..02ddd1717533 100644 --- a/seed/csharp-sdk/undiscriminated-unions/src/SeedUndiscriminatedUnions/Core/RawClient.cs +++ b/seed/csharp-sdk/undiscriminated-unions/src/SeedUndiscriminatedUnions/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/unions-with-local-date/src/SeedUnions.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/unions-with-local-date/src/SeedUnions.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/unions-with-local-date/src/SeedUnions.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/unions-with-local-date/src/SeedUnions.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/unions-with-local-date/src/SeedUnions.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/unions-with-local-date/src/SeedUnions.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..4b2df877650b --- /dev/null +++ b/seed/csharp-sdk/unions-with-local-date/src/SeedUnions.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedUnions.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/unions-with-local-date/src/SeedUnions/Core/JsonConfiguration.cs b/seed/csharp-sdk/unions-with-local-date/src/SeedUnions/Core/JsonConfiguration.cs index d46cc0334906..024269b5449d 100644 --- a/seed/csharp-sdk/unions-with-local-date/src/SeedUnions/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/unions-with-local-date/src/SeedUnions/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/unions-with-local-date/src/SeedUnions/Core/NullableAttribute.cs b/seed/csharp-sdk/unions-with-local-date/src/SeedUnions/Core/NullableAttribute.cs new file mode 100644 index 000000000000..7f5cbfcba158 --- /dev/null +++ b/seed/csharp-sdk/unions-with-local-date/src/SeedUnions/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedUnions.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/unions-with-local-date/src/SeedUnions/Core/Optional.cs b/seed/csharp-sdk/unions-with-local-date/src/SeedUnions/Core/Optional.cs new file mode 100644 index 000000000000..c2717fe55eab --- /dev/null +++ b/seed/csharp-sdk/unions-with-local-date/src/SeedUnions/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedUnions.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/unions-with-local-date/src/SeedUnions/Core/OptionalAttribute.cs b/seed/csharp-sdk/unions-with-local-date/src/SeedUnions/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..70b98f622f3a --- /dev/null +++ b/seed/csharp-sdk/unions-with-local-date/src/SeedUnions/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedUnions.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/unions-with-local-date/src/SeedUnions/Core/RawClient.cs b/seed/csharp-sdk/unions-with-local-date/src/SeedUnions/Core/RawClient.cs index 7664f1eb4f00..ce5aa7f58956 100644 --- a/seed/csharp-sdk/unions-with-local-date/src/SeedUnions/Core/RawClient.cs +++ b/seed/csharp-sdk/unions-with-local-date/src/SeedUnions/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/unions/no-custom-config/src/SeedUnions.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/unions/no-custom-config/src/SeedUnions.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/unions/no-custom-config/src/SeedUnions.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/unions/no-custom-config/src/SeedUnions.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/unions/no-custom-config/src/SeedUnions.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/unions/no-custom-config/src/SeedUnions.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..4b2df877650b --- /dev/null +++ b/seed/csharp-sdk/unions/no-custom-config/src/SeedUnions.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedUnions.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/unions/no-custom-config/src/SeedUnions/Core/JsonConfiguration.cs b/seed/csharp-sdk/unions/no-custom-config/src/SeedUnions/Core/JsonConfiguration.cs index d46cc0334906..024269b5449d 100644 --- a/seed/csharp-sdk/unions/no-custom-config/src/SeedUnions/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/unions/no-custom-config/src/SeedUnions/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/unions/no-custom-config/src/SeedUnions/Core/NullableAttribute.cs b/seed/csharp-sdk/unions/no-custom-config/src/SeedUnions/Core/NullableAttribute.cs new file mode 100644 index 000000000000..7f5cbfcba158 --- /dev/null +++ b/seed/csharp-sdk/unions/no-custom-config/src/SeedUnions/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedUnions.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/unions/no-custom-config/src/SeedUnions/Core/Optional.cs b/seed/csharp-sdk/unions/no-custom-config/src/SeedUnions/Core/Optional.cs new file mode 100644 index 000000000000..c2717fe55eab --- /dev/null +++ b/seed/csharp-sdk/unions/no-custom-config/src/SeedUnions/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedUnions.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/unions/no-custom-config/src/SeedUnions/Core/OptionalAttribute.cs b/seed/csharp-sdk/unions/no-custom-config/src/SeedUnions/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..70b98f622f3a --- /dev/null +++ b/seed/csharp-sdk/unions/no-custom-config/src/SeedUnions/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedUnions.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/unions/no-custom-config/src/SeedUnions/Core/RawClient.cs b/seed/csharp-sdk/unions/no-custom-config/src/SeedUnions/Core/RawClient.cs index 7664f1eb4f00..ce5aa7f58956 100644 --- a/seed/csharp-sdk/unions/no-custom-config/src/SeedUnions/Core/RawClient.cs +++ b/seed/csharp-sdk/unions/no-custom-config/src/SeedUnions/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/unions/no-discriminated-unions/src/SeedUnions.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/unions/no-discriminated-unions/src/SeedUnions.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/unions/no-discriminated-unions/src/SeedUnions.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/unions/no-discriminated-unions/src/SeedUnions.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/unions/no-discriminated-unions/src/SeedUnions.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/unions/no-discriminated-unions/src/SeedUnions.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..4b2df877650b --- /dev/null +++ b/seed/csharp-sdk/unions/no-discriminated-unions/src/SeedUnions.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedUnions.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/unions/no-discriminated-unions/src/SeedUnions/Core/JsonConfiguration.cs b/seed/csharp-sdk/unions/no-discriminated-unions/src/SeedUnions/Core/JsonConfiguration.cs index d46cc0334906..024269b5449d 100644 --- a/seed/csharp-sdk/unions/no-discriminated-unions/src/SeedUnions/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/unions/no-discriminated-unions/src/SeedUnions/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/unions/no-discriminated-unions/src/SeedUnions/Core/NullableAttribute.cs b/seed/csharp-sdk/unions/no-discriminated-unions/src/SeedUnions/Core/NullableAttribute.cs new file mode 100644 index 000000000000..7f5cbfcba158 --- /dev/null +++ b/seed/csharp-sdk/unions/no-discriminated-unions/src/SeedUnions/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedUnions.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/unions/no-discriminated-unions/src/SeedUnions/Core/Optional.cs b/seed/csharp-sdk/unions/no-discriminated-unions/src/SeedUnions/Core/Optional.cs new file mode 100644 index 000000000000..c2717fe55eab --- /dev/null +++ b/seed/csharp-sdk/unions/no-discriminated-unions/src/SeedUnions/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedUnions.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/unions/no-discriminated-unions/src/SeedUnions/Core/OptionalAttribute.cs b/seed/csharp-sdk/unions/no-discriminated-unions/src/SeedUnions/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..70b98f622f3a --- /dev/null +++ b/seed/csharp-sdk/unions/no-discriminated-unions/src/SeedUnions/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedUnions.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/unions/no-discriminated-unions/src/SeedUnions/Core/RawClient.cs b/seed/csharp-sdk/unions/no-discriminated-unions/src/SeedUnions/Core/RawClient.cs index 7664f1eb4f00..ce5aa7f58956 100644 --- a/seed/csharp-sdk/unions/no-discriminated-unions/src/SeedUnions/Core/RawClient.cs +++ b/seed/csharp-sdk/unions/no-discriminated-unions/src/SeedUnions/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/unknown/src/SeedUnknownAsAny.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/unknown/src/SeedUnknownAsAny.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/unknown/src/SeedUnknownAsAny.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/unknown/src/SeedUnknownAsAny.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/unknown/src/SeedUnknownAsAny.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/unknown/src/SeedUnknownAsAny.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..d930518c9662 --- /dev/null +++ b/seed/csharp-sdk/unknown/src/SeedUnknownAsAny.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedUnknownAsAny.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/unknown/src/SeedUnknownAsAny/Core/JsonConfiguration.cs b/seed/csharp-sdk/unknown/src/SeedUnknownAsAny/Core/JsonConfiguration.cs index 8fd754f807aa..2e0ee706a3d7 100644 --- a/seed/csharp-sdk/unknown/src/SeedUnknownAsAny/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/unknown/src/SeedUnknownAsAny/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/unknown/src/SeedUnknownAsAny/Core/NullableAttribute.cs b/seed/csharp-sdk/unknown/src/SeedUnknownAsAny/Core/NullableAttribute.cs new file mode 100644 index 000000000000..414bdb2c843a --- /dev/null +++ b/seed/csharp-sdk/unknown/src/SeedUnknownAsAny/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedUnknownAsAny.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/unknown/src/SeedUnknownAsAny/Core/Optional.cs b/seed/csharp-sdk/unknown/src/SeedUnknownAsAny/Core/Optional.cs new file mode 100644 index 000000000000..19c89bbc7b13 --- /dev/null +++ b/seed/csharp-sdk/unknown/src/SeedUnknownAsAny/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedUnknownAsAny.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/unknown/src/SeedUnknownAsAny/Core/OptionalAttribute.cs b/seed/csharp-sdk/unknown/src/SeedUnknownAsAny/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..6aa7d4ead586 --- /dev/null +++ b/seed/csharp-sdk/unknown/src/SeedUnknownAsAny/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedUnknownAsAny.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/unknown/src/SeedUnknownAsAny/Core/RawClient.cs b/seed/csharp-sdk/unknown/src/SeedUnknownAsAny/Core/RawClient.cs index b3f9ea27292d..4466fb858aca 100644 --- a/seed/csharp-sdk/unknown/src/SeedUnknownAsAny/Core/RawClient.cs +++ b/seed/csharp-sdk/unknown/src/SeedUnknownAsAny/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/url-form-encoded/src/SeedApi.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/url-form-encoded/src/SeedApi.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/url-form-encoded/src/SeedApi.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/url-form-encoded/src/SeedApi.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/url-form-encoded/src/SeedApi.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/url-form-encoded/src/SeedApi.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..dc732084c15f --- /dev/null +++ b/seed/csharp-sdk/url-form-encoded/src/SeedApi.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedApi.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/url-form-encoded/src/SeedApi/Core/JsonConfiguration.cs b/seed/csharp-sdk/url-form-encoded/src/SeedApi/Core/JsonConfiguration.cs index 321193691456..df6b2c46945d 100644 --- a/seed/csharp-sdk/url-form-encoded/src/SeedApi/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/url-form-encoded/src/SeedApi/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/url-form-encoded/src/SeedApi/Core/NullableAttribute.cs b/seed/csharp-sdk/url-form-encoded/src/SeedApi/Core/NullableAttribute.cs new file mode 100644 index 000000000000..8fca30535a2a --- /dev/null +++ b/seed/csharp-sdk/url-form-encoded/src/SeedApi/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedApi.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/url-form-encoded/src/SeedApi/Core/Optional.cs b/seed/csharp-sdk/url-form-encoded/src/SeedApi/Core/Optional.cs new file mode 100644 index 000000000000..4a9691feca1f --- /dev/null +++ b/seed/csharp-sdk/url-form-encoded/src/SeedApi/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedApi.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/url-form-encoded/src/SeedApi/Core/OptionalAttribute.cs b/seed/csharp-sdk/url-form-encoded/src/SeedApi/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..b2bc05f7244f --- /dev/null +++ b/seed/csharp-sdk/url-form-encoded/src/SeedApi/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedApi.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/url-form-encoded/src/SeedApi/Core/RawClient.cs b/seed/csharp-sdk/url-form-encoded/src/SeedApi/Core/RawClient.cs index 72e34bbc0e89..b60968e4eb03 100644 --- a/seed/csharp-sdk/url-form-encoded/src/SeedApi/Core/RawClient.cs +++ b/seed/csharp-sdk/url-form-encoded/src/SeedApi/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/validation/src/SeedValidation.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/validation/src/SeedValidation.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/validation/src/SeedValidation.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/validation/src/SeedValidation.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/validation/src/SeedValidation.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/validation/src/SeedValidation.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..55a2ca2ac5a4 --- /dev/null +++ b/seed/csharp-sdk/validation/src/SeedValidation.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedValidation.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/validation/src/SeedValidation/Core/JsonConfiguration.cs b/seed/csharp-sdk/validation/src/SeedValidation/Core/JsonConfiguration.cs index 3267c1c013d9..a124243933fc 100644 --- a/seed/csharp-sdk/validation/src/SeedValidation/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/validation/src/SeedValidation/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/validation/src/SeedValidation/Core/NullableAttribute.cs b/seed/csharp-sdk/validation/src/SeedValidation/Core/NullableAttribute.cs new file mode 100644 index 000000000000..0f639a61cb88 --- /dev/null +++ b/seed/csharp-sdk/validation/src/SeedValidation/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedValidation.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/validation/src/SeedValidation/Core/Optional.cs b/seed/csharp-sdk/validation/src/SeedValidation/Core/Optional.cs new file mode 100644 index 000000000000..971edb4c5a3e --- /dev/null +++ b/seed/csharp-sdk/validation/src/SeedValidation/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedValidation.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/validation/src/SeedValidation/Core/OptionalAttribute.cs b/seed/csharp-sdk/validation/src/SeedValidation/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..9d0b8598defd --- /dev/null +++ b/seed/csharp-sdk/validation/src/SeedValidation/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedValidation.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/validation/src/SeedValidation/Core/RawClient.cs b/seed/csharp-sdk/validation/src/SeedValidation/Core/RawClient.cs index 6ca51612c0ce..cb7f627dbbb9 100644 --- a/seed/csharp-sdk/validation/src/SeedValidation/Core/RawClient.cs +++ b/seed/csharp-sdk/validation/src/SeedValidation/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/variables/src/SeedVariables.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/variables/src/SeedVariables.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/variables/src/SeedVariables.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/variables/src/SeedVariables.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/variables/src/SeedVariables.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/variables/src/SeedVariables.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..638f3809b01a --- /dev/null +++ b/seed/csharp-sdk/variables/src/SeedVariables.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedVariables.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/variables/src/SeedVariables/Core/JsonConfiguration.cs b/seed/csharp-sdk/variables/src/SeedVariables/Core/JsonConfiguration.cs index 807a419443f9..532fc09a49ee 100644 --- a/seed/csharp-sdk/variables/src/SeedVariables/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/variables/src/SeedVariables/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/variables/src/SeedVariables/Core/NullableAttribute.cs b/seed/csharp-sdk/variables/src/SeedVariables/Core/NullableAttribute.cs new file mode 100644 index 000000000000..7d493a9cdba8 --- /dev/null +++ b/seed/csharp-sdk/variables/src/SeedVariables/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedVariables.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/variables/src/SeedVariables/Core/Optional.cs b/seed/csharp-sdk/variables/src/SeedVariables/Core/Optional.cs new file mode 100644 index 000000000000..44e312393c23 --- /dev/null +++ b/seed/csharp-sdk/variables/src/SeedVariables/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedVariables.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/variables/src/SeedVariables/Core/OptionalAttribute.cs b/seed/csharp-sdk/variables/src/SeedVariables/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..44ea83fd9f58 --- /dev/null +++ b/seed/csharp-sdk/variables/src/SeedVariables/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedVariables.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/variables/src/SeedVariables/Core/RawClient.cs b/seed/csharp-sdk/variables/src/SeedVariables/Core/RawClient.cs index 369dc4b3ac80..19043642bb24 100644 --- a/seed/csharp-sdk/variables/src/SeedVariables/Core/RawClient.cs +++ b/seed/csharp-sdk/variables/src/SeedVariables/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/version-no-default/src/SeedVersion.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/version-no-default/src/SeedVersion.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/version-no-default/src/SeedVersion.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/version-no-default/src/SeedVersion.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/version-no-default/src/SeedVersion.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/version-no-default/src/SeedVersion.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..d28d0962f5ec --- /dev/null +++ b/seed/csharp-sdk/version-no-default/src/SeedVersion.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedVersion.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/version-no-default/src/SeedVersion/Core/JsonConfiguration.cs b/seed/csharp-sdk/version-no-default/src/SeedVersion/Core/JsonConfiguration.cs index 920fb056ab41..841cb56b99e4 100644 --- a/seed/csharp-sdk/version-no-default/src/SeedVersion/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/version-no-default/src/SeedVersion/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/version-no-default/src/SeedVersion/Core/NullableAttribute.cs b/seed/csharp-sdk/version-no-default/src/SeedVersion/Core/NullableAttribute.cs new file mode 100644 index 000000000000..5fa4ebdc3ad6 --- /dev/null +++ b/seed/csharp-sdk/version-no-default/src/SeedVersion/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedVersion.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/version-no-default/src/SeedVersion/Core/Optional.cs b/seed/csharp-sdk/version-no-default/src/SeedVersion/Core/Optional.cs new file mode 100644 index 000000000000..d83249be4527 --- /dev/null +++ b/seed/csharp-sdk/version-no-default/src/SeedVersion/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedVersion.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/version-no-default/src/SeedVersion/Core/OptionalAttribute.cs b/seed/csharp-sdk/version-no-default/src/SeedVersion/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..9a9293fea517 --- /dev/null +++ b/seed/csharp-sdk/version-no-default/src/SeedVersion/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedVersion.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/version-no-default/src/SeedVersion/Core/RawClient.cs b/seed/csharp-sdk/version-no-default/src/SeedVersion/Core/RawClient.cs index 1df05bf9aa37..04d019bd7549 100644 --- a/seed/csharp-sdk/version-no-default/src/SeedVersion/Core/RawClient.cs +++ b/seed/csharp-sdk/version-no-default/src/SeedVersion/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/version/src/SeedVersion.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/version/src/SeedVersion.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/version/src/SeedVersion.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/version/src/SeedVersion.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/version/src/SeedVersion.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/version/src/SeedVersion.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..d28d0962f5ec --- /dev/null +++ b/seed/csharp-sdk/version/src/SeedVersion.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedVersion.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/version/src/SeedVersion/Core/JsonConfiguration.cs b/seed/csharp-sdk/version/src/SeedVersion/Core/JsonConfiguration.cs index 920fb056ab41..841cb56b99e4 100644 --- a/seed/csharp-sdk/version/src/SeedVersion/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/version/src/SeedVersion/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/version/src/SeedVersion/Core/NullableAttribute.cs b/seed/csharp-sdk/version/src/SeedVersion/Core/NullableAttribute.cs new file mode 100644 index 000000000000..5fa4ebdc3ad6 --- /dev/null +++ b/seed/csharp-sdk/version/src/SeedVersion/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedVersion.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/version/src/SeedVersion/Core/Optional.cs b/seed/csharp-sdk/version/src/SeedVersion/Core/Optional.cs new file mode 100644 index 000000000000..d83249be4527 --- /dev/null +++ b/seed/csharp-sdk/version/src/SeedVersion/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedVersion.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/version/src/SeedVersion/Core/OptionalAttribute.cs b/seed/csharp-sdk/version/src/SeedVersion/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..9a9293fea517 --- /dev/null +++ b/seed/csharp-sdk/version/src/SeedVersion/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedVersion.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/version/src/SeedVersion/Core/RawClient.cs b/seed/csharp-sdk/version/src/SeedVersion/Core/RawClient.cs index 1df05bf9aa37..04d019bd7549 100644 --- a/seed/csharp-sdk/version/src/SeedVersion/Core/RawClient.cs +++ b/seed/csharp-sdk/version/src/SeedVersion/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/websocket-bearer-auth/src/SeedWebsocketBearerAuth.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/websocket-bearer-auth/src/SeedWebsocketBearerAuth.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/websocket-bearer-auth/src/SeedWebsocketBearerAuth.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/websocket-bearer-auth/src/SeedWebsocketBearerAuth.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/websocket-bearer-auth/src/SeedWebsocketBearerAuth.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/websocket-bearer-auth/src/SeedWebsocketBearerAuth.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..ef7692cc0549 --- /dev/null +++ b/seed/csharp-sdk/websocket-bearer-auth/src/SeedWebsocketBearerAuth.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedWebsocketBearerAuth.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/websocket-bearer-auth/src/SeedWebsocketBearerAuth/Core/JsonConfiguration.cs b/seed/csharp-sdk/websocket-bearer-auth/src/SeedWebsocketBearerAuth/Core/JsonConfiguration.cs index b74af4743932..1bb2a93a97c5 100644 --- a/seed/csharp-sdk/websocket-bearer-auth/src/SeedWebsocketBearerAuth/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/websocket-bearer-auth/src/SeedWebsocketBearerAuth/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/websocket-bearer-auth/src/SeedWebsocketBearerAuth/Core/NullableAttribute.cs b/seed/csharp-sdk/websocket-bearer-auth/src/SeedWebsocketBearerAuth/Core/NullableAttribute.cs new file mode 100644 index 000000000000..f8850e75a4dd --- /dev/null +++ b/seed/csharp-sdk/websocket-bearer-auth/src/SeedWebsocketBearerAuth/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedWebsocketBearerAuth.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/websocket-bearer-auth/src/SeedWebsocketBearerAuth/Core/Optional.cs b/seed/csharp-sdk/websocket-bearer-auth/src/SeedWebsocketBearerAuth/Core/Optional.cs new file mode 100644 index 000000000000..65c75c469500 --- /dev/null +++ b/seed/csharp-sdk/websocket-bearer-auth/src/SeedWebsocketBearerAuth/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedWebsocketBearerAuth.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/websocket-bearer-auth/src/SeedWebsocketBearerAuth/Core/OptionalAttribute.cs b/seed/csharp-sdk/websocket-bearer-auth/src/SeedWebsocketBearerAuth/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..b24e640afaeb --- /dev/null +++ b/seed/csharp-sdk/websocket-bearer-auth/src/SeedWebsocketBearerAuth/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedWebsocketBearerAuth.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/websocket-bearer-auth/src/SeedWebsocketBearerAuth/Core/RawClient.cs b/seed/csharp-sdk/websocket-bearer-auth/src/SeedWebsocketBearerAuth/Core/RawClient.cs index 2b87f0ee58a8..2203c35d18b4 100644 --- a/seed/csharp-sdk/websocket-bearer-auth/src/SeedWebsocketBearerAuth/Core/RawClient.cs +++ b/seed/csharp-sdk/websocket-bearer-auth/src/SeedWebsocketBearerAuth/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/websocket-inferred-auth/src/SeedWebsocketAuth.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/websocket-inferred-auth/src/SeedWebsocketAuth.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/websocket-inferred-auth/src/SeedWebsocketAuth.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/websocket-inferred-auth/src/SeedWebsocketAuth.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/websocket-inferred-auth/src/SeedWebsocketAuth.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/websocket-inferred-auth/src/SeedWebsocketAuth.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..587ed941dc07 --- /dev/null +++ b/seed/csharp-sdk/websocket-inferred-auth/src/SeedWebsocketAuth.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedWebsocketAuth.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/websocket-inferred-auth/src/SeedWebsocketAuth/Core/JsonConfiguration.cs b/seed/csharp-sdk/websocket-inferred-auth/src/SeedWebsocketAuth/Core/JsonConfiguration.cs index 9db206a73df6..5d10fdf82200 100644 --- a/seed/csharp-sdk/websocket-inferred-auth/src/SeedWebsocketAuth/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/websocket-inferred-auth/src/SeedWebsocketAuth/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/websocket-inferred-auth/src/SeedWebsocketAuth/Core/NullableAttribute.cs b/seed/csharp-sdk/websocket-inferred-auth/src/SeedWebsocketAuth/Core/NullableAttribute.cs new file mode 100644 index 000000000000..24100c59c653 --- /dev/null +++ b/seed/csharp-sdk/websocket-inferred-auth/src/SeedWebsocketAuth/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedWebsocketAuth.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/websocket-inferred-auth/src/SeedWebsocketAuth/Core/Optional.cs b/seed/csharp-sdk/websocket-inferred-auth/src/SeedWebsocketAuth/Core/Optional.cs new file mode 100644 index 000000000000..600c4e529993 --- /dev/null +++ b/seed/csharp-sdk/websocket-inferred-auth/src/SeedWebsocketAuth/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedWebsocketAuth.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/websocket-inferred-auth/src/SeedWebsocketAuth/Core/OptionalAttribute.cs b/seed/csharp-sdk/websocket-inferred-auth/src/SeedWebsocketAuth/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..85a10a69bc04 --- /dev/null +++ b/seed/csharp-sdk/websocket-inferred-auth/src/SeedWebsocketAuth/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedWebsocketAuth.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/websocket-inferred-auth/src/SeedWebsocketAuth/Core/RawClient.cs b/seed/csharp-sdk/websocket-inferred-auth/src/SeedWebsocketAuth/Core/RawClient.cs index 6c59fa791219..2a45ed3cf436 100644 --- a/seed/csharp-sdk/websocket-inferred-auth/src/SeedWebsocketAuth/Core/RawClient.cs +++ b/seed/csharp-sdk/websocket-inferred-auth/src/SeedWebsocketAuth/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/websocket/no-custom-config/src/SeedWebsocket.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/websocket/no-custom-config/src/SeedWebsocket.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/websocket/no-custom-config/src/SeedWebsocket.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/websocket/no-custom-config/src/SeedWebsocket.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/websocket/no-custom-config/src/SeedWebsocket.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/websocket/no-custom-config/src/SeedWebsocket.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..0d51ac0c6629 --- /dev/null +++ b/seed/csharp-sdk/websocket/no-custom-config/src/SeedWebsocket.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedWebsocket.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/websocket/no-custom-config/src/SeedWebsocket/Core/JsonConfiguration.cs b/seed/csharp-sdk/websocket/no-custom-config/src/SeedWebsocket/Core/JsonConfiguration.cs index c147d51681fc..96c3d6c53949 100644 --- a/seed/csharp-sdk/websocket/no-custom-config/src/SeedWebsocket/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/websocket/no-custom-config/src/SeedWebsocket/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/websocket/no-custom-config/src/SeedWebsocket/Core/NullableAttribute.cs b/seed/csharp-sdk/websocket/no-custom-config/src/SeedWebsocket/Core/NullableAttribute.cs new file mode 100644 index 000000000000..af9b86376350 --- /dev/null +++ b/seed/csharp-sdk/websocket/no-custom-config/src/SeedWebsocket/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedWebsocket.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/websocket/no-custom-config/src/SeedWebsocket/Core/Optional.cs b/seed/csharp-sdk/websocket/no-custom-config/src/SeedWebsocket/Core/Optional.cs new file mode 100644 index 000000000000..6cf0121d24f5 --- /dev/null +++ b/seed/csharp-sdk/websocket/no-custom-config/src/SeedWebsocket/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedWebsocket.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/websocket/no-custom-config/src/SeedWebsocket/Core/OptionalAttribute.cs b/seed/csharp-sdk/websocket/no-custom-config/src/SeedWebsocket/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..a851979cee4c --- /dev/null +++ b/seed/csharp-sdk/websocket/no-custom-config/src/SeedWebsocket/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedWebsocket.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/websocket/no-custom-config/src/SeedWebsocket/Core/RawClient.cs b/seed/csharp-sdk/websocket/no-custom-config/src/SeedWebsocket/Core/RawClient.cs index 6967a43d266c..f168868a117d 100644 --- a/seed/csharp-sdk/websocket/no-custom-config/src/SeedWebsocket/Core/RawClient.cs +++ b/seed/csharp-sdk/websocket/no-custom-config/src/SeedWebsocket/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket.Test/Utils/NUnitExtensions.cs b/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket.Test/Utils/NUnitExtensions.cs index 426df1245388..78e90e0a90fc 100644 --- a/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket.Test/Utils/NUnitExtensions.cs +++ b/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket.Test/Utils/NUnitExtensions.cs @@ -24,5 +24,6 @@ public static EqualConstraint UsingDefaults(this EqualConstraint constraint) => .UsingReadOnlyMemoryComparer() .UsingReadOnlyMemoryComparer() .UsingOneOfComparer() - .UsingJsonElementComparer(); + .UsingJsonElementComparer() + .UsingOptionalComparer(); } diff --git a/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket.Test/Utils/OptionalComparer.cs b/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket.Test/Utils/OptionalComparer.cs new file mode 100644 index 000000000000..0d51ac0c6629 --- /dev/null +++ b/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket.Test/Utils/OptionalComparer.cs @@ -0,0 +1,60 @@ +using NUnit.Framework.Constraints; +using SeedWebsocket.Core; + +namespace NUnit.Framework; + +/// +/// Extensions for EqualConstraint to handle Optional values. +/// +public static class OptionalComparerExtensions +{ + /// + /// Modifies the EqualConstraint to handle Optional instances by comparing their IsDefined state and inner values. + /// This works alongside other comparison modifiers like UsingPropertiesComparer. + /// + /// The EqualConstraint to modify. + /// The same constraint instance for method chaining. + public static EqualConstraint UsingOptionalComparer(this EqualConstraint constraint) + { + // Register a comparer factory for IOptional types + constraint.Using( + (x, y) => + { + // Both must have the same IsDefined state + if (x.IsDefined != y.IsDefined) + { + return false; + } + + // If both are undefined, they're equal + if (!x.IsDefined) + { + return true; + } + + // Both are defined, compare their boxed values + var xValue = x.GetBoxedValue(); + var yValue = y.GetBoxedValue(); + + // ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (xValue is null && yValue is null) + { + return true; + } + + if (xValue is null || yValue is null) + { + return false; + } + + // Use NUnit's property comparer for the inner values + var propertiesComparer = new NUnitEqualityComparer(); + var tolerance = Tolerance.Default; + propertiesComparer.CompareProperties = true; + return propertiesComparer.AreEqual(xValue, yValue, ref tolerance); + } + ); + + return constraint; + } +} diff --git a/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/Async/Models/Options.cs b/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/Async/Models/Options.cs deleted file mode 100644 index cc486e9610ba..000000000000 --- a/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/Async/Models/Options.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.ComponentModel; -using System.Runtime.CompilerServices; - -namespace SeedWebsocket.Core.Async.Models; - -/// -/// Abstract base class for asynchronous API configuration options. -/// Provides common configuration properties for WebSocket-based API connections. -/// -public abstract class AsyncApiOptions -{ - /// - /// Gets or sets the base URL for the API connection. - /// - virtual public string BaseUrl { get; set; } = ""; -} diff --git a/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/JsonConfiguration.cs b/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/JsonConfiguration.cs index b863169f734e..1d26d58e2080 100644 --- a/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/JsonConfiguration.cs +++ b/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/JsonConfiguration.cs @@ -14,11 +14,15 @@ static JsonOptions() { var options = new JsonSerializerOptions { - Converters = { new DateTimeSerializer(), + Converters = + { + new DateTimeSerializer(), #if USE_PORTABLE_DATE_ONLY new DateOnlyConverter(), #endif - new OneOfSerializer() }, + new OneOfSerializer(), + new OptionalJsonConverterFactory(), + }, #if DEBUG WriteIndented = true, #endif @@ -27,75 +31,9 @@ static JsonOptions() { Modifiers = { - static typeInfo => - { - if (typeInfo.Kind != JsonTypeInfoKind.Object) - return; - - foreach (var propertyInfo in typeInfo.Properties) - { - var jsonAccessAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonAccessAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonAccessAttribute != null) - { - propertyInfo.IsRequired = false; - switch (jsonAccessAttribute.AccessType) - { - case JsonAccessType.ReadOnly: - propertyInfo.ShouldSerialize = (_, _) => false; - break; - case JsonAccessType.WriteOnly: - propertyInfo.Set = null; - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - var jsonIgnoreAttribute = propertyInfo - .AttributeProvider?.GetCustomAttributes( - typeof(JsonIgnoreAttribute), - true - ) - .OfType() - .FirstOrDefault(); - - if (jsonIgnoreAttribute is not null) - { - propertyInfo.IsRequired = false; - } - } - - if ( - typeInfo.Kind == JsonTypeInfoKind.Object - && typeInfo.Properties.All(prop => !prop.IsExtensionData) - ) - { - var extensionProp = typeInfo - .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) - .FirstOrDefault(prop => - prop.GetCustomAttribute() != null - ); - - if (extensionProp is not null) - { - var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( - extensionProp.FieldType, - extensionProp.Name - ); - jsonPropertyInfo.Get = extensionProp.GetValue; - jsonPropertyInfo.Set = extensionProp.SetValue; - jsonPropertyInfo.IsExtensionData = true; - typeInfo.Properties.Add(jsonPropertyInfo); - } - } - }, + NullableOptionalModifier, + JsonAccessAndIgnoreModifier, + HandleExtensionDataFields, }, }, }; @@ -103,6 +41,139 @@ static JsonOptions() JsonSerializerOptions = options; } + private static void NullableOptionalModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var property in typeInfo.Properties) + { + var propertyInfo = property.AttributeProvider as global::System.Reflection.PropertyInfo; + + if (propertyInfo == null) + continue; + + // Check for ReadOnly JsonAccessAttribute - it overrides Optional/Nullable behavior + var jsonAccessAttribute = propertyInfo.GetCustomAttribute(); + if (jsonAccessAttribute?.AccessType == JsonAccessType.ReadOnly) + { + // ReadOnly means "never serialize", which completely overrides Optional/Nullable. + // Skip Optional/Nullable processing since JsonAccessAndIgnoreModifier + // will set ShouldSerialize = false anyway. + continue; + } + // Note: WriteOnly doesn't conflict with Optional/Nullable since it only + // affects deserialization (Set), not serialization (ShouldSerialize) + + var isOptionalType = + property.PropertyType.IsGenericType + && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>); + + var hasOptionalAttribute = propertyInfo.GetCustomAttribute() != null; + var hasNullableAttribute = propertyInfo.GetCustomAttribute() != null; + + if (isOptionalType && hasOptionalAttribute) + { + var originalGetter = property.Get; + if (originalGetter != null) + { + var capturedIsNullable = hasNullableAttribute; + + property.ShouldSerialize = (obj, value) => + { + var optionalValue = originalGetter(obj); + if (optionalValue is not IOptional optional) + return false; + + if (!optional.IsDefined) + return false; + + if (!capturedIsNullable) + { + var innerValue = optional.GetBoxedValue(); + if (innerValue == null) + return false; + } + + return true; + }; + } + } + else if (hasNullableAttribute) + { + // Force serialization of nullable properties even when null + property.ShouldSerialize = (obj, value) => true; + } + } + } + + private static void JsonAccessAndIgnoreModifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) + return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var jsonAccessAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonAccessAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonAccessAttribute != null) + { + propertyInfo.IsRequired = false; + switch (jsonAccessAttribute.AccessType) + { + case JsonAccessType.ReadOnly: + propertyInfo.ShouldSerialize = (_, _) => false; + break; + case JsonAccessType.WriteOnly: + propertyInfo.Set = null; + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + var jsonIgnoreAttribute = propertyInfo + .AttributeProvider?.GetCustomAttributes(typeof(JsonIgnoreAttribute), true) + .OfType() + .FirstOrDefault(); + + if (jsonIgnoreAttribute is not null) + { + propertyInfo.IsRequired = false; + } + } + } + + private static void HandleExtensionDataFields(JsonTypeInfo typeInfo) + { + if ( + typeInfo.Kind == JsonTypeInfoKind.Object + && typeInfo.Properties.All(prop => !prop.IsExtensionData) + ) + { + var extensionProp = typeInfo + .Type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .FirstOrDefault(prop => + prop.GetCustomAttribute() != null + ); + + if (extensionProp is not null) + { + var jsonPropertyInfo = typeInfo.CreateJsonPropertyInfo( + extensionProp.FieldType, + extensionProp.Name + ); + jsonPropertyInfo.Get = extensionProp.GetValue; + jsonPropertyInfo.Set = extensionProp.SetValue; + jsonPropertyInfo.IsExtensionData = true; + typeInfo.Properties.Add(jsonPropertyInfo); + } + } + } + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); } diff --git a/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/NullableAttribute.cs b/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/NullableAttribute.cs new file mode 100644 index 000000000000..af9b86376350 --- /dev/null +++ b/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/NullableAttribute.cs @@ -0,0 +1,18 @@ +namespace SeedWebsocket.Core; + +/// +/// Marks a property as nullable in the OpenAPI specification. +/// When applied to Optional properties, this indicates that null values should be +/// written to JSON when the optional is defined with null. +/// +/// +/// For regular (required) properties: +/// - Without [Nullable]: null values are invalid (omit from JSON at runtime) +/// - With [Nullable]: null values are written to JSON +/// +/// For Optional properties (also marked with [Optional]): +/// - Without [Nullable]: Optional.Of(null) → omit from JSON (runtime edge case) +/// - With [Nullable]: Optional.Of(null) → write null to JSON +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class NullableAttribute : Attribute { } diff --git a/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/Optional.cs b/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/Optional.cs new file mode 100644 index 000000000000..6cf0121d24f5 --- /dev/null +++ b/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/Optional.cs @@ -0,0 +1,474 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedWebsocket.Core; + +/// +/// Non-generic interface for Optional types to enable reflection-free checks. +/// +public interface IOptional +{ + /// + /// Returns true if the value is defined (set), even if the value is null. + /// + bool IsDefined { get; } + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + object? GetBoxedValue(); +} + +/// +/// Represents a field that can be "not set" (undefined) vs "explicitly set" (defined). +/// Use this for HTTP PATCH requests where you need to distinguish between: +/// +/// Undefined: Don't send this field (leave it unchanged on the server) +/// Defined with null: Send null (clear the field on the server) +/// Defined with value: Send the value (update the field on the server) +/// +/// +/// The type of the value. Use nullable types (T?) for fields that can be null. +/// +/// For nullable string fields, use Optional<string?>: +/// +/// public class UpdateUserRequest +/// { +/// public Optional<string?> Name { get; set; } = Optional<string?>.Undefined; +/// } +/// +/// var request = new UpdateUserRequest +/// { +/// Name = "John" // Will send: { "name": "John" } +/// }; +/// +/// var request2 = new UpdateUserRequest +/// { +/// Name = Optional<string?>.Of(null) // Will send: { "name": null } +/// }; +/// +/// var request3 = new UpdateUserRequest(); // Will send: {} (name not included) +/// +/// +public readonly struct Optional : IOptional, IEquatable> +{ + private readonly T _value; + private readonly bool _isDefined; + + private Optional(T value, bool isDefined) + { + _value = value; + _isDefined = isDefined; + } + + /// + /// Creates an undefined value - the field will not be included in the HTTP request. + /// Use this as the default value for optional fields. + /// + /// + /// + /// public Optional<string?> Email { get; set; } = Optional<string?>.Undefined; + /// + /// + public static Optional Undefined => new(default!, false); + + /// + /// Creates a defined value - the field will be included in the HTTP request. + /// The value can be null if T is a nullable type. + /// + /// The value to set. Can be null if T is nullable (e.g., string?, int?). + /// + /// + /// // Set to a value + /// request.Name = Optional<string?>.Of("John"); + /// + /// // Set to null (clears the field) + /// request.Email = Optional<string?>.Of(null); + /// + /// // Or use implicit conversion + /// request.Name = "John"; // Same as Of("John") + /// request.Email = null; // Same as Of(null) + /// + /// + public static Optional Of(T value) => new(value, true); + + /// + /// Returns true if the field is defined (set), even if the value is null. + /// Use this to determine if the field should be included in the HTTP request. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// requestBody["name"] = request.Name.Value; // Include in request (can be null) + /// } + /// + /// + public bool IsDefined => _isDefined; + + /// + /// Returns true if the field is undefined (not set). + /// Use this to check if the field should be excluded from the HTTP request. + /// + /// + /// + /// if (request.Email.IsUndefined) + /// { + /// // Don't include email in the request - leave it unchanged + /// } + /// + /// + public bool IsUndefined => !_isDefined; + + /// + /// Gets the value. The value may be null if T is a nullable type. + /// + /// Thrown if the value is undefined. + /// + /// Always check before accessing Value, or use instead. + /// + /// + /// + /// if (request.Name.IsDefined) + /// { + /// string? name = request.Name.Value; // Safe - can be null if Optional<string?> + /// } + /// + /// // Or check for null explicitly + /// if (request.Email.IsDefined && request.Email.Value is null) + /// { + /// // Email is explicitly set to null (clear it) + /// } + /// + /// + public T Value + { + get + { + if (!_isDefined) + throw new InvalidOperationException("Optional value is undefined"); + return _value; + } + } + + /// + /// Gets the value if defined, otherwise returns the specified default value. + /// Note: If the value is defined as null, this returns null (not the default). + /// + /// The value to return if undefined. + /// The actual value if defined (can be null), otherwise the default value. + /// + /// + /// string name = request.Name.GetValueOrDefault("Anonymous"); + /// // If Name is undefined: returns "Anonymous" + /// // If Name is Of(null): returns null + /// // If Name is Of("John"): returns "John" + /// + /// + public T GetValueOrDefault(T defaultValue = default!) + { + return _isDefined ? _value : defaultValue; + } + + /// + /// Tries to get the value. Returns true if the value is defined (even if null). + /// + /// + /// When this method returns, contains the value if defined, or default(T) if undefined. + /// The value may be null if T is nullable. + /// + /// True if the value is defined; otherwise, false. + /// + /// + /// if (request.Email.TryGetValue(out var email)) + /// { + /// requestBody["email"] = email; // email can be null + /// } + /// else + /// { + /// // Email is undefined - don't include in request + /// } + /// + /// + public bool TryGetValue(out T value) + { + if (_isDefined) + { + value = _value; + return true; + } + value = default!; + return false; + } + + /// + /// Implicitly converts a value to Optional<T>.Of(value). + /// This allows natural assignment: request.Name = "John" instead of request.Name = Optional<string?>.Of("John"). + /// + /// The value to convert (can be null if T is nullable). + public static implicit operator Optional(T value) => Of(value); + + /// + /// Returns a string representation of this Optional value. + /// + /// "Undefined" if not set, or "Defined(value)" if set. + public override string ToString() => _isDefined ? $"Defined({_value})" : "Undefined"; + + /// + /// Gets the boxed value. Returns null if undefined or if the value is null. + /// + public object? GetBoxedValue() + { + if (!_isDefined) + return null; + return _value; + } + + /// + public bool Equals(Optional other) => + _isDefined == other._isDefined && EqualityComparer.Default.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is Optional other && Equals(other); + + /// + public override int GetHashCode() + { + if (!_isDefined) + return 0; + unchecked + { + int hash = 17; + hash = hash * 31 + 1; // _isDefined = true + hash = hash * 31 + (_value is null ? 0 : _value.GetHashCode()); + return hash; + } + } + + /// + /// Determines whether two Optional values are equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are equal; otherwise, false. + public static bool operator ==(Optional left, Optional right) => left.Equals(right); + + /// + /// Determines whether two Optional values are not equal. + /// + /// The first Optional to compare. + /// The second Optional to compare. + /// True if the Optional values are not equal; otherwise, false. + public static bool operator !=(Optional left, Optional right) => !left.Equals(right); +} + +/// +/// Extension methods for Optional to simplify common operations. +/// +public static class OptionalExtensions +{ + /// + /// Adds the value to a dictionary if the optional is defined (even if the value is null). + /// This is useful for building JSON request payloads where null values should be included. + /// + /// The type of the optional value. + /// The optional value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Name.AddTo(dict, "name"); // Adds only if Name.IsDefined + /// request.Email.AddTo(dict, "email"); // Adds only if Email.IsDefined + /// + /// + public static void AddTo( + this Optional optional, + Dictionary dictionary, + string key + ) + { + if (optional.IsDefined) + { + dictionary[key] = optional.Value; + } + } + + /// + /// Executes an action if the optional is defined. + /// + /// The type of the optional value. + /// The optional value. + /// The action to execute with the value. + /// + /// + /// request.Name.IfDefined(name => Console.WriteLine($"Name: {name}")); + /// + /// + public static void IfDefined(this Optional optional, Action action) + { + if (optional.IsDefined) + { + action(optional.Value); + } + } + + /// + /// Maps the value to a new type if the optional is defined, otherwise returns undefined. + /// + /// The type of the original value. + /// The type to map to. + /// The optional value to map. + /// The mapping function. + /// An optional containing the mapped value if defined, otherwise undefined. + /// + /// + /// Optional<string?> name = Optional<string?>.Of("John"); + /// Optional<int> length = name.Map(n => n?.Length ?? 0); // Optional.Of(4) + /// + /// + public static Optional Map( + this Optional optional, + Func mapper + ) + { + return optional.IsDefined + ? Optional.Of(mapper(optional.Value)) + : Optional.Undefined; + } + + /// + /// Adds a nullable value to a dictionary only if it is not null. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The type of the value (must be a reference type or Nullable). + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Description.AddIfNotNull(dict, "description"); // Only adds if not null + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if not null + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : class + { + if (value is not null) + { + dictionary[key] = value; + } + } + + /// + /// Adds a nullable value type to a dictionary only if it has a value. + /// This is useful for regular nullable properties where null means "omit from request". + /// + /// The underlying value type. + /// The nullable value to add. + /// The dictionary to add to. + /// The key to use in the dictionary. + /// + /// + /// var dict = new Dictionary<string, object?>(); + /// request.Age.AddIfNotNull(dict, "age"); // Only adds if HasValue + /// request.Score.AddIfNotNull(dict, "score"); // Only adds if HasValue + /// + /// + public static void AddIfNotNull( + this T? value, + Dictionary dictionary, + string key + ) + where T : struct + { + if (value.HasValue) + { + dictionary[key] = value.Value; + } + } +} + +/// +/// JSON converter factory for Optional that handles undefined vs null correctly. +/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined. +/// +public class OptionalJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(global::System.Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + return false; + + return typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + } + + public override JsonConverter? CreateConverter( + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var valueType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalJsonConverter<>).MakeGenericType(valueType); + return (JsonConverter?)global::System.Activator.CreateInstance(converterType); + } +} + +/// +/// JSON converter for Optional that unwraps the value during serialization. +/// The actual property skipping is handled by the OptionalTypeInfoResolver. +/// +public class OptionalJsonConverter : JsonConverter> +{ + public override Optional Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Optional.Of(default!); + } + + var value = JsonSerializer.Deserialize(ref reader, options); + return Optional.Of(value!); + } + + public override void Write( + Utf8JsonWriter writer, + Optional value, + JsonSerializerOptions options + ) + { + // This will be called by the serializer + // We need to unwrap and serialize the inner value + // The TypeInfoResolver will handle skipping undefined values + + if (value.IsUndefined) + { + // This shouldn't be called for undefined values due to ShouldSerialize + // But if it is, write null and let the resolver filter it + writer.WriteNullValue(); + return; + } + + // Get the inner value + var innerValue = value.Value; + + // Write null directly if the value is null (don't use JsonSerializer.Serialize for null) + if (innerValue == null) + { + writer.WriteNullValue(); + return; + } + + // Serialize the unwrapped value + JsonSerializer.Serialize(writer, innerValue, options); + } +} diff --git a/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/OptionalAttribute.cs b/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/OptionalAttribute.cs new file mode 100644 index 000000000000..a851979cee4c --- /dev/null +++ b/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/OptionalAttribute.cs @@ -0,0 +1,17 @@ +namespace SeedWebsocket.Core; + +/// +/// Marks a property as optional in the OpenAPI specification. +/// Optional properties use the Optional type and can be undefined (not present in JSON). +/// +/// +/// Properties marked with [Optional] should use the Optional type: +/// - Undefined: Optional.Undefined → omitted from JSON +/// - Defined: Optional.Of(value) → written to JSON +/// +/// Combine with [Nullable] to allow null values: +/// - [Optional, Nullable] Optional → can be undefined, null, or a value +/// - [Optional] Optional → can be undefined or a value (null is invalid) +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class OptionalAttribute : Attribute { } diff --git a/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/RawClient.cs b/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/RawClient.cs index 6967a43d266c..f168868a117d 100644 --- a/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/RawClient.cs +++ b/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/RawClient.cs @@ -355,6 +355,9 @@ Dictionary inputDict { switch (kvp.Value) { + case null: + result.Add(new KeyValuePair(kvp.Key, "")); + break; case string str: result.Add(new KeyValuePair(kvp.Key, str)); break; diff --git a/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/Async/Threading/AsyncLock.cs b/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/WebSockets/AsyncLock.cs similarity index 97% rename from seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/Async/Threading/AsyncLock.cs rename to seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/WebSockets/AsyncLock.cs index 1680a435784b..d5433fa0b800 100644 --- a/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/Async/Threading/AsyncLock.cs +++ b/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/WebSockets/AsyncLock.cs @@ -1,8 +1,8 @@ -// ReSharper disable All +// ReSharper disable All // #pragma warning disable // #pragma warning disable CS8600 // #pragma warning disable CS8619 -namespace SeedWebsocket.Core.Async.Threading; +namespace SeedWebsocket.Core.WebSockets; /// /// Provides a convenient wrapper around SemaphoreSlim that enables easy use of locking inside 'using' blocks. diff --git a/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/Async/Events/Closed.cs b/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/WebSockets/Closed.cs similarity index 89% rename from seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/Async/Events/Closed.cs rename to seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/WebSockets/Closed.cs index 850be77dcf2c..f53b347e52d1 100644 --- a/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/Async/Events/Closed.cs +++ b/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/WebSockets/Closed.cs @@ -1,4 +1,4 @@ -namespace SeedWebsocket.Core.Async.Events; +namespace SeedWebsocket.Core.WebSockets; /// /// Event arguments for when the connection with the async service is closed. diff --git a/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/Async/Events/Connected.cs b/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/WebSockets/Connected.cs similarity index 77% rename from seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/Async/Events/Connected.cs rename to seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/WebSockets/Connected.cs index ec28d2395bbc..318b7b4fdce7 100644 --- a/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/Async/Events/Connected.cs +++ b/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/WebSockets/Connected.cs @@ -1,4 +1,4 @@ -namespace SeedWebsocket.Core.Async.Events; +namespace SeedWebsocket.Core.WebSockets; /// /// Event arguments for when the connection with the async service is established. diff --git a/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/Async/ConnectionStatus.cs b/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/WebSockets/ConnectionStatus.cs similarity index 93% rename from seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/Async/ConnectionStatus.cs rename to seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/WebSockets/ConnectionStatus.cs index 797de9660198..1a619520db09 100644 --- a/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/Async/ConnectionStatus.cs +++ b/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/WebSockets/ConnectionStatus.cs @@ -1,4 +1,4 @@ -namespace SeedWebsocket.Core.Async; +namespace SeedWebsocket.Core.WebSockets; /// /// Represents the current state of an asynchronous connection. diff --git a/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/Async/Models/DisconnectionInfo.cs b/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/WebSockets/DisconnectionInfo.cs similarity index 97% rename from seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/Async/Models/DisconnectionInfo.cs rename to seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/WebSockets/DisconnectionInfo.cs index e850f750ace7..2f3101fd2217 100644 --- a/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/Async/Models/DisconnectionInfo.cs +++ b/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/WebSockets/DisconnectionInfo.cs @@ -1,8 +1,8 @@ -// ReSharper disable All +// ReSharper disable All #pragma warning disable using System.Net.WebSockets; -namespace SeedWebsocket.Core.Async.Models; +namespace SeedWebsocket.Core.WebSockets; /// /// Contains information about a WebSocket disconnection event. diff --git a/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/Async/Models/DisconnectionType.cs b/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/WebSockets/DisconnectionType.cs similarity index 92% rename from seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/Async/Models/DisconnectionType.cs rename to seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/WebSockets/DisconnectionType.cs index f39a043457fa..e5c6d4f2596e 100644 --- a/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/Async/Models/DisconnectionType.cs +++ b/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/WebSockets/DisconnectionType.cs @@ -1,6 +1,6 @@ -// ReSharper disable All +// ReSharper disable All #pragma warning disable -namespace SeedWebsocket.Core.Async.Models; +namespace SeedWebsocket.Core.WebSockets; /// /// Specifies the type of disconnection that occurred in a WebSocket connection. diff --git a/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/Async/Events/Event.cs b/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/WebSockets/Event.cs similarity index 98% rename from seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/Async/Events/Event.cs rename to seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/WebSockets/Event.cs index 59164bcd7627..df704427e425 100644 --- a/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/Async/Events/Event.cs +++ b/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/WebSockets/Event.cs @@ -1,5 +1,5 @@ // ReSharper disable UnusedMember.Global -namespace SeedWebsocket.Core.Async.Events; +namespace SeedWebsocket.Core.WebSockets; /// /// Wraps an event that can be subscribed to and can be invoked. diff --git a/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/Async/Query.cs b/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/WebSockets/Query.cs similarity index 99% rename from seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/Async/Query.cs rename to seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/WebSockets/Query.cs index d4143d8ec38b..7baaabbf7890 100644 --- a/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/Async/Query.cs +++ b/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/WebSockets/Query.cs @@ -1,6 +1,6 @@ using System.Collections; -namespace SeedWebsocket.Core.Async; +namespace SeedWebsocket.Core.WebSockets; /// /// Represents a collection of query parameters that can be used to build URL query strings. diff --git a/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/Async/Models/ReconnectionInfo.cs b/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/WebSockets/ReconnectionInfo.cs similarity index 93% rename from seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/Async/Models/ReconnectionInfo.cs rename to seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/WebSockets/ReconnectionInfo.cs index 8e549c98a3a0..7fdecdc749d4 100644 --- a/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/Async/Models/ReconnectionInfo.cs +++ b/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/WebSockets/ReconnectionInfo.cs @@ -1,6 +1,6 @@ -// ReSharper disable All +// ReSharper disable All #pragma warning disable -namespace SeedWebsocket.Core.Async.Models; +namespace SeedWebsocket.Core.WebSockets; /// /// Contains information about a WebSocket reconnection event. diff --git a/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/Async/Models/ReconnectionType.cs b/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/WebSockets/ReconnectionType.cs similarity index 92% rename from seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/Async/Models/ReconnectionType.cs rename to seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/WebSockets/ReconnectionType.cs index fa390ec0be69..d2d096f4276e 100644 --- a/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/Async/Models/ReconnectionType.cs +++ b/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/WebSockets/ReconnectionType.cs @@ -1,6 +1,6 @@ -// ReSharper disable All +// ReSharper disable All // #pragma warning disable -namespace SeedWebsocket.Core.Async.Models; +namespace SeedWebsocket.Core.WebSockets; /// /// Specifies the type of reconnection that occurred in a WebSocket connection. diff --git a/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/Async/RequestMessage.cs b/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/WebSockets/RequestMessage.cs similarity index 96% rename from seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/Async/RequestMessage.cs rename to seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/WebSockets/RequestMessage.cs index 9be382c3b53a..3d0b056967d8 100644 --- a/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/Async/RequestMessage.cs +++ b/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/WebSockets/RequestMessage.cs @@ -1,6 +1,6 @@ -// ReSharper disable All +// ReSharper disable All // #pragma warning disable -namespace SeedWebsocket.Core.Async; +namespace SeedWebsocket.Core.WebSockets; /// /// Abstract base class for WebSocket request messages. diff --git a/generators/csharp/base/src/asIs/Async/AsyncApi.Template.cs b/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/WebSockets/WebSocketClient.cs similarity index 52% rename from generators/csharp/base/src/asIs/Async/AsyncApi.Template.cs rename to seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/WebSockets/WebSocketClient.cs index c047b4451670..43b1204ebc94 100644 --- a/generators/csharp/base/src/asIs/Async/AsyncApi.Template.cs +++ b/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/WebSockets/WebSocketClient.cs @@ -1,107 +1,55 @@ using System.ComponentModel; -using System.Diagnostics.CodeAnalysis; using System.Net.WebSockets; using System.Runtime.CompilerServices; -using <%= namespace%>.Async.Events; -using <%= namespace%>.Async.Models; -namespace <%= namespace%>.Async; +namespace SeedWebsocket.Core.WebSockets; /// -/// Abstract base class for asynchronous API implementations that use WebSocket connections. -/// Provides common functionality for connection management, message sending, and event handling. +/// A WebSocket client that handles connection management, message sending, and event handling. /// -/// The type of API options that must inherit from AsyncApiOptions. -public abstract class AsyncApi : IAsyncDisposable, IDisposable, INotifyPropertyChanged - where T : AsyncApiOptions +internal sealed class WebSocketClient : IAsyncDisposable, IDisposable, INotifyPropertyChanged { - private T _apiOptions; - private WebSocketConnection? _webSocket; private ConnectionStatus _status = ConnectionStatus.Disconnected; + private WebSocketConnection? _webSocket; + private readonly Uri _uri; + private readonly Func _onTextMessage; /// - /// Initializes a new instance of the AsyncApi class with the specified options. - /// - /// The API configuration options. - protected internal AsyncApi(T options) - { - _apiOptions = options; - } - - /// - /// Creates the WebSocket URI for the connection. - /// - /// The URI to connect to. - protected abstract Uri CreateUri(); - - /// - /// Disposes any custom events specific to the derived class. - /// - protected abstract void DisposeEvents(); - - /// - /// Configures the WebSocket connection options before establishing the connection. - /// - /// The WebSocket client options to configure. - protected abstract void SetConnectionOptions(ClientWebSocketOptions options); - - /// - /// Handles incoming text messages from the WebSocket connection. - /// - /// The stream containing the received text message. - /// A task representing the asynchronous operation. - protected abstract Task OnTextMessage(Stream stream); - - /// - /// Handles incoming binary messages from the WebSocket connection. - /// - /// Override this method to handle binary message content. - /// (Default behavior is to do nothing) - /// - /// The stream containing the received binary message. - /// A task representing the asynchronous operation. - protected virtual Task OnBinaryMessage(Stream stream) { - stream.Dispose(); - return Task.CompletedTask; - } - - /// - /// Gets or sets the API configuration options. + /// Initializes a new instance of the WebSocketClient class. /// - public T ApiOptions + /// The WebSocket URI to connect to. + /// Handler for incoming text messages. + public WebSocketClient(Uri uri, Func onTextMessage) { - get => _apiOptions; - protected set => - NotifyIfPropertyChanged( - EqualityComparer.Default.Equals(_apiOptions, value), - _apiOptions = value - ); + _uri = uri; + _onTextMessage = onTextMessage; } /// - /// Gets or sets the base URL for the API connection. + /// Gets the current connection status of the WebSocket. /// - public string BaseUrl + public ConnectionStatus Status { - get => ApiOptions.BaseUrl; - protected set => - NotifyIfPropertyChanged( - EqualityComparer.Default.Equals(ApiOptions.BaseUrl), - ApiOptions.BaseUrl = value - ); + get => _status; + private set + { + if (_status != value) + { + _status = value; + OnPropertyChanged(); + } + } } /// - /// Gets the current connection status of the WebSocket. + /// Ensures the WebSocket is connected before sending. /// - public ConnectionStatus Status + private void EnsureConnected() { - get => _status; - protected set => - NotifyIfPropertyChanged( - EqualityComparer.Default.Equals(_status, value), - _status = value - ); + this.Assert( + Status == ConnectionStatus.Connected, + $"Cannot send message when status is {Status}" + ); } /// @@ -110,12 +58,9 @@ public ConnectionStatus Status /// The text message to send. /// A task representing the asynchronous send operation. /// Thrown when the connection is not in Connected status. - protected internal Task SendInstant(string message) + public Task SendInstant(string message) { - this.Assert( - Status == ConnectionStatus.Connected, - $"Cannot send message when status is {Status}" - ); + EnsureConnected(); return _webSocket!.SendInstant(message); } @@ -125,12 +70,9 @@ protected internal Task SendInstant(string message) /// The binary message to send as a Memory<byte>. /// A task representing the asynchronous send operation. /// Thrown when the connection is not in Connected status. - protected internal Task SendInstant(Memory message) + public Task SendInstant(Memory message) { - this.Assert( - Status == ConnectionStatus.Connected, - $"Cannot send message when status is {Status}" - ); + EnsureConnected(); return _webSocket!.SendInstant(message); } @@ -140,12 +82,9 @@ protected internal Task SendInstant(Memory message) /// The binary message to send as an ArraySegment<byte>. /// A task representing the asynchronous send operation. /// Thrown when the connection is not in Connected status. - protected internal Task SendInstant(ArraySegment message) + public Task SendInstant(ArraySegment message) { - this.Assert( - Status == ConnectionStatus.Connected, - $"Cannot send message when status is {Status}" - ); + EnsureConnected(); return _webSocket!.SendInstant(message); } @@ -155,17 +94,14 @@ protected internal Task SendInstant(ArraySegment message) /// The binary message to send as a byte array. /// A task representing the asynchronous send operation. /// Thrown when the connection is not in Connected status. - protected internal Task SendInstant(byte[] message) + public Task SendInstant(byte[] message) { - this.Assert( - Status == ConnectionStatus.Connected, - $"Cannot send message when status is {Status}" - ); + EnsureConnected(); return _webSocket!.SendInstant(message); } /// - /// Asynchronously disposes the AsyncApi instance, closing any active connections and cleaning up resources. + /// Asynchronously disposes the WebSocketClient instance, closing any active connections and cleaning up resources. /// /// A ValueTask representing the asynchronous dispose operation. public async ValueTask DisposeAsync() @@ -186,7 +122,7 @@ public async ValueTask DisposeAsync() } /// - /// Synchronously disposes the AsyncApi instance, closing any active connections and cleaning up resources. + /// Synchronously disposes the WebSocketClient instance, closing any active connections and cleaning up resources. /// public void Dispose() { @@ -205,21 +141,20 @@ public void Dispose() } /// - /// Disposes all internal events and calls the derived class's DisposeEvents method. + /// Disposes all internal events. /// private void DisposeEventsInternal() { ExceptionOccurred.Dispose(); Closed.Dispose(); Connected.Dispose(); - DisposeEvents(); } /// /// Asynchronously closes the WebSocket connection with normal closure status. /// /// A task representing the asynchronous close operation. - public virtual async Task CloseAsync() + public async Task CloseAsync() { if (_webSocket != null) { @@ -234,28 +169,26 @@ public virtual async Task CloseAsync() /// /// A task representing the asynchronous connect operation. /// Thrown when the connection status is not Disconnected or when connection fails. - public virtual async Task ConnectAsync() + public async Task ConnectAsync() { - this.Assert(Status == ConnectionStatus.Disconnected, $"Connection status is currently {Status}"); + this.Assert( + Status == ConnectionStatus.Disconnected, + $"Connection status is currently {Status}" + ); _webSocket?.Dispose(); - // the websocket connection is connecting to the target url Status = ConnectionStatus.Connecting; - - _webSocket = new WebSocketConnection( - CreateUri(), - () => - { - var socket = new ClientWebSocket(); - SetConnectionOptions(socket.Options); - return socket; - } - ) + + _webSocket = new WebSocketConnection(_uri, () => new ClientWebSocket()) { ExceptionOccurred = ExceptionOccurred.RaiseEvent, - TextMessageReceived = OnTextMessage, - BinaryMessageReceived = OnBinaryMessage, + TextMessageReceived = _onTextMessage, + BinaryMessageReceived = stream => + { + stream.Dispose(); + return Task.CompletedTask; + }, DisconnectionHappened = async d => { await Closed @@ -266,8 +199,6 @@ await Closed }, }; - - try { await _webSocket.StartOrFail().ConfigureAwait(false); @@ -279,8 +210,6 @@ await Closed Status = ConnectionStatus.Disconnected; throw; } - - // connection has been established } /// @@ -300,25 +229,16 @@ await Closed /// /// Event that is raised when a property value changes. + /// Currently only raised for the Status property. /// public event PropertyChangedEventHandler? PropertyChanged; /// - /// Notifies subscribers of the PropertyChanged event if the property value has actually changed. + /// Raises the PropertyChanged event. /// - /// The type of the property value. - /// True if the old and new values are equal, false otherwise. - /// The new property value. /// The name of the property that changed. Automatically populated by the compiler. - protected void NotifyIfPropertyChanged( - bool isEqual, - TValue value, - [CallerMemberName] string? propertyName = null - ) + private void OnPropertyChanged([CallerMemberName] string? propertyName = null) { - if (isEqual == false) - { - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); - } + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } } diff --git a/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/Async/WebSocketConnection.Sending.cs b/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/WebSockets/WebSocketConnection.Sending.cs similarity index 98% rename from seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/Async/WebSocketConnection.Sending.cs rename to seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/WebSockets/WebSocketConnection.Sending.cs index af80f74e1064..c9bb8e2a3277 100644 --- a/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/Async/WebSocketConnection.Sending.cs +++ b/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/WebSockets/WebSocketConnection.Sending.cs @@ -1,9 +1,9 @@ -// ReSharper disable All +// ReSharper disable All #pragma warning disable using System.Net.WebSockets; using System.Text; -namespace SeedWebsocket.Core.Async; +namespace SeedWebsocket.Core.WebSockets; internal partial class WebSocketConnection { diff --git a/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/Async/WebSocketConnection.cs b/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/WebSockets/WebSocketConnection.cs similarity index 98% rename from seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/Async/WebSocketConnection.cs rename to seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/WebSockets/WebSocketConnection.cs index e35a3b406671..27c961d7bdbc 100644 --- a/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/Async/WebSocketConnection.cs +++ b/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/WebSockets/WebSocketConnection.cs @@ -5,11 +5,8 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.IO; -using SeedWebsocket.Core.Async.Exceptions; -using SeedWebsocket.Core.Async.Models; -using SeedWebsocket.Core.Async.Threading; -namespace SeedWebsocket.Core.Async; +namespace SeedWebsocket.Core.WebSockets; /// /// A simple websocket client with built-in reconnection and error handling diff --git a/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/Async/Exceptions/WebsocketException.cs b/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/WebSockets/WebsocketException.cs similarity index 94% rename from seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/Async/Exceptions/WebsocketException.cs rename to seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/WebSockets/WebsocketException.cs index 05afa1a47ec1..849996ba8144 100644 --- a/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/Async/Exceptions/WebsocketException.cs +++ b/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Core/WebSockets/WebsocketException.cs @@ -1,6 +1,6 @@ -// ReSharper disable All +// ReSharper disable All #pragma warning disable -namespace SeedWebsocket.Core.Async.Exceptions; +namespace SeedWebsocket.Core.WebSockets; /// /// Custom exception related to WebSocket connection operations. diff --git a/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Empty/EmptyRealtime/EmptyRealtimeApi.cs b/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Empty/EmptyRealtime/EmptyRealtimeApi.cs index e57d7f7a15cc..4f5ace0fa53c 100644 --- a/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Empty/EmptyRealtime/EmptyRealtimeApi.cs +++ b/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Empty/EmptyRealtime/EmptyRealtimeApi.cs @@ -1,40 +1,69 @@ -using System.Net.WebSockets; +using System.ComponentModel; using System.Text.Json; -using SeedWebsocket.Core.Async; -using SeedWebsocket.Core.Async.Models; +using SeedWebsocket.Core.WebSockets; namespace SeedWebsocket.Empty; -public partial class EmptyRealtimeApi : AsyncApi +public partial class EmptyRealtimeApi : IAsyncDisposable, IDisposable, INotifyPropertyChanged { + private readonly EmptyRealtimeApi.Options _options; + + private readonly WebSocketClient _client; + /// - /// Default constructor + /// Event that is raised when a property value changes. /// - public EmptyRealtimeApi() - : this(new EmptyRealtimeApi.Options()) { } + public event PropertyChangedEventHandler PropertyChanged + { + add => _client.PropertyChanged += value; + remove => _client.PropertyChanged -= value; + } /// - /// Constructor with options + /// Default constructor /// - public EmptyRealtimeApi(EmptyRealtimeApi.Options options) - : base(options) { } + public EmptyRealtimeApi() { } /// - /// Creates the Uri for the websocket connection from the BaseUrl and parameters + /// Constructor with options /// - protected override Uri CreateUri() + public EmptyRealtimeApi(EmptyRealtimeApi.Options options) { - var uri = new UriBuilder(BaseUrl); + _options = options; + var uri = new UriBuilder(_options.BaseUrl); uri.Path = $"{uri.Path.TrimEnd('/')}/empty/realtime"; - return uri.Uri; + _client = new WebSocketClient(uri.Uri, OnTextMessage); } - protected override void SetConnectionOptions(ClientWebSocketOptions options) { } + /// + /// Gets the current connection status of the WebSocket. + /// + public ConnectionStatus Status => _client.Status; + + /// + /// Event that is raised when the WebSocket connection is established. + /// + public Event Connected => _client.Connected; + + /// + /// Event that is raised when the WebSocket connection is closed. + /// + public Event Closed => _client.Closed; + + /// + /// Event that is raised when an exception occurs during WebSocket operations. + /// + public Event ExceptionOccurred => _client.ExceptionOccurred; + + /// + /// Disposes of event subscriptions + /// + private void DisposeEvents() { } /// /// Dispatches incoming WebSocket messages /// - protected async override Task OnTextMessage(Stream stream) + private async Task OnTextMessage(Stream stream) { var json = await JsonSerializer.DeserializeAsync(stream); if (json == null) @@ -52,18 +81,49 @@ await ExceptionOccurred } /// - /// Disposes of event subscriptions + /// Asynchronously establishes a WebSocket connection. /// - protected override void DisposeEvents() { } + public async Task ConnectAsync() + { + await _client.ConnectAsync().ConfigureAwait(false); + } + + /// + /// Asynchronously closes the WebSocket connection. + /// + public async Task CloseAsync() + { + await _client.CloseAsync().ConfigureAwait(false); + } + + /// + /// Asynchronously disposes the WebSocket client, closing any active connections and cleaning up resources. + /// + public async ValueTask DisposeAsync() + { + await _client.DisposeAsync(); + DisposeEvents(); + GC.SuppressFinalize(this); + } + + /// + /// Synchronously disposes the WebSocket client, closing any active connections and cleaning up resources. + /// + public void Dispose() + { + _client.Dispose(); + DisposeEvents(); + GC.SuppressFinalize(this); + } /// /// Options for the API client /// - public class Options : AsyncApiOptions + public class Options { /// /// The Websocket URL for the API connection. /// - override public string BaseUrl { get; set; } = ""; + public string BaseUrl { get; set; } = ""; } } diff --git a/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Realtime/RealtimeApi.cs b/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Realtime/RealtimeApi.cs index 4766f1d13fc6..6edc3a3c32b4 100644 --- a/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Realtime/RealtimeApi.cs +++ b/seed/csharp-sdk/websocket/with-websockets/src/SeedWebsocket/Realtime/RealtimeApi.cs @@ -1,14 +1,25 @@ -using System.Net.WebSockets; +using System.ComponentModel; using System.Text.Json; using SeedWebsocket.Core; -using SeedWebsocket.Core.Async; -using SeedWebsocket.Core.Async.Events; -using SeedWebsocket.Core.Async.Models; +using SeedWebsocket.Core.WebSockets; namespace SeedWebsocket; -public partial class RealtimeApi : AsyncApi +public partial class RealtimeApi : IAsyncDisposable, IDisposable, INotifyPropertyChanged { + private readonly RealtimeApi.Options _options; + + private readonly WebSocketClient _client; + + /// + /// Event that is raised when a property value changes. + /// + public event PropertyChangedEventHandler PropertyChanged + { + add => _client.PropertyChanged += value; + remove => _client.PropertyChanged -= value; + } + /// /// Event handler for ReceiveEvent. /// Use ReceiveEvent.Subscribe(...) to receive messages. @@ -37,72 +48,56 @@ public partial class RealtimeApi : AsyncApi /// Constructor with options /// public RealtimeApi(RealtimeApi.Options options) - : base(options) { } - - public string SessionId { - get => ApiOptions.SessionId; - set => - NotifyIfPropertyChanged( - EqualityComparer.Default.Equals(ApiOptions.SessionId), - ApiOptions.SessionId = value - ); + _options = options; + var uri = new UriBuilder(_options.BaseUrl) + { + Query = new Query() + { + { "model", _options.Model }, + { "temperature", _options.Temperature }, + { "language-code", _options.LanguageCode }, + }, + }; + uri.Path = $"{uri.Path.TrimEnd('/')}/realtime/{Uri.EscapeDataString(_options.SessionId)}"; + _client = new WebSocketClient(uri.Uri, OnTextMessage); } - public string? Model - { - get => ApiOptions.Model; - set => - NotifyIfPropertyChanged( - EqualityComparer.Default.Equals(ApiOptions.Model), - ApiOptions.Model = value - ); - } + /// + /// Gets the current connection status of the WebSocket. + /// + public ConnectionStatus Status => _client.Status; - public int? Temperature - { - get => ApiOptions.Temperature; - set => - NotifyIfPropertyChanged( - EqualityComparer.Default.Equals(ApiOptions.Temperature), - ApiOptions.Temperature = value - ); - } + /// + /// Event that is raised when the WebSocket connection is established. + /// + public Event Connected => _client.Connected; - public string? LanguageCode - { - get => ApiOptions.LanguageCode; - set => - NotifyIfPropertyChanged( - EqualityComparer.Default.Equals(ApiOptions.LanguageCode), - ApiOptions.LanguageCode = value - ); - } + /// + /// Event that is raised when the WebSocket connection is closed. + /// + public Event Closed => _client.Closed; /// - /// Creates the Uri for the websocket connection from the BaseUrl and parameters + /// Event that is raised when an exception occurs during WebSocket operations. /// - protected override Uri CreateUri() + public Event ExceptionOccurred => _client.ExceptionOccurred; + + /// + /// Disposes of event subscriptions + /// + private void DisposeEvents() { - var uri = new UriBuilder(BaseUrl) - { - Query = new Query() - { - { "model", Model }, - { "temperature", Temperature }, - { "language-code", LanguageCode }, - }, - }; - uri.Path = $"{uri.Path.TrimEnd('/')}/realtime/{Uri.EscapeDataString(SessionId)}"; - return uri.Uri; + ReceiveEvent.Dispose(); + ReceiveSnakeCase.Dispose(); + ReceiveEvent2.Dispose(); + ReceiveEvent3.Dispose(); } - protected override void SetConnectionOptions(ClientWebSocketOptions options) { } - /// /// Dispatches incoming WebSocket messages /// - protected async override Task OnTextMessage(Stream stream) + private async Task OnTextMessage(Stream stream) { var json = await JsonSerializer.DeserializeAsync(stream); if (json == null) @@ -152,14 +147,39 @@ await ExceptionOccurred } /// - /// Disposes of event subscriptions + /// Asynchronously establishes a WebSocket connection. /// - protected override void DisposeEvents() + public async Task ConnectAsync() { - ReceiveEvent.Dispose(); - ReceiveSnakeCase.Dispose(); - ReceiveEvent2.Dispose(); - ReceiveEvent3.Dispose(); + await _client.ConnectAsync().ConfigureAwait(false); + } + + /// + /// Asynchronously closes the WebSocket connection. + /// + public async Task CloseAsync() + { + await _client.CloseAsync().ConfigureAwait(false); + } + + /// + /// Asynchronously disposes the WebSocket client, closing any active connections and cleaning up resources. + /// + public async ValueTask DisposeAsync() + { + await _client.DisposeAsync(); + DisposeEvents(); + GC.SuppressFinalize(this); + } + + /// + /// Synchronously disposes the WebSocket client, closing any active connections and cleaning up resources. + /// + public void Dispose() + { + _client.Dispose(); + DisposeEvents(); + GC.SuppressFinalize(this); } /// @@ -167,7 +187,7 @@ protected override void DisposeEvents() /// public async Task Send(SendEvent message) { - await SendInstant(JsonUtils.Serialize(message)).ConfigureAwait(false); + await _client.SendInstant(JsonUtils.Serialize(message)).ConfigureAwait(false); } /// @@ -175,7 +195,7 @@ public async Task Send(SendEvent message) /// public async Task Send(SendSnakeCase message) { - await SendInstant(JsonUtils.Serialize(message)).ConfigureAwait(false); + await _client.SendInstant(JsonUtils.Serialize(message)).ConfigureAwait(false); } /// @@ -183,18 +203,18 @@ public async Task Send(SendSnakeCase message) /// public async Task Send(SendEvent2 message) { - await SendInstant(JsonUtils.Serialize(message)).ConfigureAwait(false); + await _client.SendInstant(JsonUtils.Serialize(message)).ConfigureAwait(false); } /// /// Options for the API client /// - public class Options : AsyncApiOptions + public class Options { /// /// The Websocket URL for the API connection. /// - override public string BaseUrl { get; set; } = ""; + public string BaseUrl { get; set; } = ""; public string? Model { get; set; } diff --git a/seed/java-sdk/query-parameters-openapi/src/main/java/com/seed/api/AsyncRawSeedApiClient.java b/seed/java-sdk/query-parameters-openapi/src/main/java/com/seed/api/AsyncRawSeedApiClient.java index e6a5f8c7a44d..8cc514e38fd2 100644 --- a/seed/java-sdk/query-parameters-openapi/src/main/java/com/seed/api/AsyncRawSeedApiClient.java +++ b/seed/java-sdk/query-parameters-openapi/src/main/java/com/seed/api/AsyncRawSeedApiClient.java @@ -83,6 +83,11 @@ public CompletableFuture> search( QueryStringMapper.addQueryParameter( httpUrl, "filter", request.getFilter().get(), true); } + if (requestOptions != null) { + requestOptions.getQueryParameters().forEach((key, value) -> { + httpUrl.addQueryParameter(key, value); + }); + } Request.Builder _requestBuilder = new Request.Builder() .url(httpUrl.build()) .method("GET", null) diff --git a/seed/java-sdk/query-parameters-openapi/src/main/java/com/seed/api/RawSeedApiClient.java b/seed/java-sdk/query-parameters-openapi/src/main/java/com/seed/api/RawSeedApiClient.java index 81479ecac029..a295e6406885 100644 --- a/seed/java-sdk/query-parameters-openapi/src/main/java/com/seed/api/RawSeedApiClient.java +++ b/seed/java-sdk/query-parameters-openapi/src/main/java/com/seed/api/RawSeedApiClient.java @@ -78,6 +78,11 @@ public SeedApiHttpResponse search(SearchRequest request, Request QueryStringMapper.addQueryParameter( httpUrl, "filter", request.getFilter().get(), true); } + if (requestOptions != null) { + requestOptions.getQueryParameters().forEach((key, value) -> { + httpUrl.addQueryParameter(key, value); + }); + } Request.Builder _requestBuilder = new Request.Builder() .url(httpUrl.build()) .method("GET", null) diff --git a/seed/java-sdk/query-parameters-openapi/src/main/java/com/seed/api/core/RequestOptions.java b/seed/java-sdk/query-parameters-openapi/src/main/java/com/seed/api/core/RequestOptions.java index e78b8620b593..cd95a27201b2 100644 --- a/seed/java-sdk/query-parameters-openapi/src/main/java/com/seed/api/core/RequestOptions.java +++ b/seed/java-sdk/query-parameters-openapi/src/main/java/com/seed/api/core/RequestOptions.java @@ -18,15 +18,23 @@ public final class RequestOptions { private final Map> headerSuppliers; + private final Map queryParameters; + + private final Map> queryParameterSuppliers; + private RequestOptions( Optional timeout, TimeUnit timeoutTimeUnit, Map headers, - Map> headerSuppliers) { + Map> headerSuppliers, + Map queryParameters, + Map> queryParameterSuppliers) { this.timeout = timeout; this.timeoutTimeUnit = timeoutTimeUnit; this.headers = headers; this.headerSuppliers = headerSuppliers; + this.queryParameters = queryParameters; + this.queryParameterSuppliers = queryParameterSuppliers; } public Optional getTimeout() { @@ -46,6 +54,14 @@ public Map getHeaders() { return headers; } + public Map getQueryParameters() { + Map queryParameters = new HashMap<>(this.queryParameters); + this.queryParameterSuppliers.forEach((key, supplier) -> { + queryParameters.put(key, supplier.get()); + }); + return queryParameters; + } + public static Builder builder() { return new Builder(); } @@ -59,6 +75,10 @@ public static class Builder { private final Map> headerSuppliers = new HashMap<>(); + private final Map queryParameters = new HashMap<>(); + + private final Map> queryParameterSuppliers = new HashMap<>(); + public Builder timeout(Integer timeout) { this.timeout = Optional.of(timeout); return this; @@ -80,8 +100,19 @@ public Builder addHeader(String key, Supplier value) { return this; } + public Builder addQueryParameter(String key, String value) { + this.queryParameters.put(key, value); + return this; + } + + public Builder addQueryParameter(String key, Supplier value) { + this.queryParameterSuppliers.put(key, value); + return this; + } + public RequestOptions build() { - return new RequestOptions(timeout, timeoutTimeUnit, headers, headerSuppliers); + return new RequestOptions( + timeout, timeoutTimeUnit, headers, headerSuppliers, queryParameters, queryParameterSuppliers); } } } diff --git a/seed/java-sdk/query-parameters/src/main/java/com/seed/queryParameters/core/RequestOptions.java b/seed/java-sdk/query-parameters/src/main/java/com/seed/queryParameters/core/RequestOptions.java index 759661256187..21a8dad5ec3e 100644 --- a/seed/java-sdk/query-parameters/src/main/java/com/seed/queryParameters/core/RequestOptions.java +++ b/seed/java-sdk/query-parameters/src/main/java/com/seed/queryParameters/core/RequestOptions.java @@ -18,15 +18,23 @@ public final class RequestOptions { private final Map> headerSuppliers; + private final Map queryParameters; + + private final Map> queryParameterSuppliers; + private RequestOptions( Optional timeout, TimeUnit timeoutTimeUnit, Map headers, - Map> headerSuppliers) { + Map> headerSuppliers, + Map queryParameters, + Map> queryParameterSuppliers) { this.timeout = timeout; this.timeoutTimeUnit = timeoutTimeUnit; this.headers = headers; this.headerSuppliers = headerSuppliers; + this.queryParameters = queryParameters; + this.queryParameterSuppliers = queryParameterSuppliers; } public Optional getTimeout() { @@ -46,6 +54,14 @@ public Map getHeaders() { return headers; } + public Map getQueryParameters() { + Map queryParameters = new HashMap<>(this.queryParameters); + this.queryParameterSuppliers.forEach((key, supplier) -> { + queryParameters.put(key, supplier.get()); + }); + return queryParameters; + } + public static Builder builder() { return new Builder(); } @@ -59,6 +75,10 @@ public static class Builder { private final Map> headerSuppliers = new HashMap<>(); + private final Map queryParameters = new HashMap<>(); + + private final Map> queryParameterSuppliers = new HashMap<>(); + public Builder timeout(Integer timeout) { this.timeout = Optional.of(timeout); return this; @@ -80,8 +100,19 @@ public Builder addHeader(String key, Supplier value) { return this; } + public Builder addQueryParameter(String key, String value) { + this.queryParameters.put(key, value); + return this; + } + + public Builder addQueryParameter(String key, Supplier value) { + this.queryParameterSuppliers.put(key, value); + return this; + } + public RequestOptions build() { - return new RequestOptions(timeout, timeoutTimeUnit, headers, headerSuppliers); + return new RequestOptions( + timeout, timeoutTimeUnit, headers, headerSuppliers, queryParameters, queryParameterSuppliers); } } } diff --git a/seed/java-sdk/query-parameters/src/main/java/com/seed/queryParameters/resources/user/AsyncRawUserClient.java b/seed/java-sdk/query-parameters/src/main/java/com/seed/queryParameters/resources/user/AsyncRawUserClient.java index 279bc72df5f7..059972aa65aa 100644 --- a/seed/java-sdk/query-parameters/src/main/java/com/seed/queryParameters/resources/user/AsyncRawUserClient.java +++ b/seed/java-sdk/query-parameters/src/main/java/com/seed/queryParameters/resources/user/AsyncRawUserClient.java @@ -63,6 +63,11 @@ public CompletableFuture> getUsername( } QueryStringMapper.addQueryParameter(httpUrl, "excludeUser", request.getExcludeUser(), true); QueryStringMapper.addQueryParameter(httpUrl, "filter", request.getFilter(), true); + if (requestOptions != null) { + requestOptions.getQueryParameters().forEach((key, value) -> { + httpUrl.addQueryParameter(key, value); + }); + } Request.Builder _requestBuilder = new Request.Builder() .url(httpUrl.build()) .method("GET", null) diff --git a/seed/java-sdk/query-parameters/src/main/java/com/seed/queryParameters/resources/user/RawUserClient.java b/seed/java-sdk/query-parameters/src/main/java/com/seed/queryParameters/resources/user/RawUserClient.java index 366ac05a10df..f41cd87ec762 100644 --- a/seed/java-sdk/query-parameters/src/main/java/com/seed/queryParameters/resources/user/RawUserClient.java +++ b/seed/java-sdk/query-parameters/src/main/java/com/seed/queryParameters/resources/user/RawUserClient.java @@ -58,6 +58,11 @@ public SeedQueryParametersHttpResponse getUsername(GetUsersRequest request } QueryStringMapper.addQueryParameter(httpUrl, "excludeUser", request.getExcludeUser(), true); QueryStringMapper.addQueryParameter(httpUrl, "filter", request.getFilter(), true); + if (requestOptions != null) { + requestOptions.getQueryParameters().forEach((key, value) -> { + httpUrl.addQueryParameter(key, value); + }); + } Request.Builder _requestBuilder = new Request.Builder() .url(httpUrl.build()) .method("GET", null) diff --git a/seed/java-sdk/request-parameters/src/main/java/com/seed/requestParameters/core/RequestOptions.java b/seed/java-sdk/request-parameters/src/main/java/com/seed/requestParameters/core/RequestOptions.java index 1287643bd42c..7459c3d5b8c8 100644 --- a/seed/java-sdk/request-parameters/src/main/java/com/seed/requestParameters/core/RequestOptions.java +++ b/seed/java-sdk/request-parameters/src/main/java/com/seed/requestParameters/core/RequestOptions.java @@ -18,15 +18,23 @@ public final class RequestOptions { private final Map> headerSuppliers; + private final Map queryParameters; + + private final Map> queryParameterSuppliers; + private RequestOptions( Optional timeout, TimeUnit timeoutTimeUnit, Map headers, - Map> headerSuppliers) { + Map> headerSuppliers, + Map queryParameters, + Map> queryParameterSuppliers) { this.timeout = timeout; this.timeoutTimeUnit = timeoutTimeUnit; this.headers = headers; this.headerSuppliers = headerSuppliers; + this.queryParameters = queryParameters; + this.queryParameterSuppliers = queryParameterSuppliers; } public Optional getTimeout() { @@ -46,6 +54,14 @@ public Map getHeaders() { return headers; } + public Map getQueryParameters() { + Map queryParameters = new HashMap<>(this.queryParameters); + this.queryParameterSuppliers.forEach((key, supplier) -> { + queryParameters.put(key, supplier.get()); + }); + return queryParameters; + } + public static Builder builder() { return new Builder(); } @@ -59,6 +75,10 @@ public static class Builder { private final Map> headerSuppliers = new HashMap<>(); + private final Map queryParameters = new HashMap<>(); + + private final Map> queryParameterSuppliers = new HashMap<>(); + public Builder timeout(Integer timeout) { this.timeout = Optional.of(timeout); return this; @@ -80,8 +100,19 @@ public Builder addHeader(String key, Supplier value) { return this; } + public Builder addQueryParameter(String key, String value) { + this.queryParameters.put(key, value); + return this; + } + + public Builder addQueryParameter(String key, Supplier value) { + this.queryParameterSuppliers.put(key, value); + return this; + } + public RequestOptions build() { - return new RequestOptions(timeout, timeoutTimeUnit, headers, headerSuppliers); + return new RequestOptions( + timeout, timeoutTimeUnit, headers, headerSuppliers, queryParameters, queryParameterSuppliers); } } } diff --git a/seed/java-sdk/request-parameters/src/main/java/com/seed/requestParameters/resources/user/AsyncRawUserClient.java b/seed/java-sdk/request-parameters/src/main/java/com/seed/requestParameters/resources/user/AsyncRawUserClient.java index dfc05bf08915..81904eb9d8a5 100644 --- a/seed/java-sdk/request-parameters/src/main/java/com/seed/requestParameters/resources/user/AsyncRawUserClient.java +++ b/seed/java-sdk/request-parameters/src/main/java/com/seed/requestParameters/resources/user/AsyncRawUserClient.java @@ -49,6 +49,11 @@ public CompletableFuture> createUsername .addPathSegments("user") .addPathSegments("username"); QueryStringMapper.addQueryParameter(httpUrl, "tags", request.getTags(), false); + if (requestOptions != null) { + requestOptions.getQueryParameters().forEach((key, value) -> { + httpUrl.addQueryParameter(key, value); + }); + } RequestBody body; try { body = RequestBody.create( @@ -107,6 +112,11 @@ public CompletableFuture> createUsername .addPathSegments("user") .addPathSegments("username-referenced"); QueryStringMapper.addQueryParameter(httpUrl, "tags", request.getTags(), false); + if (requestOptions != null) { + requestOptions.getQueryParameters().forEach((key, value) -> { + httpUrl.addQueryParameter(key, value); + }); + } RequestBody body; try { body = RequestBody.create( @@ -169,11 +179,15 @@ public CompletableFuture> createUsername public CompletableFuture> createUsernameOptional( Optional request, RequestOptions requestOptions) { - HttpUrl httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) + HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("user") - .addPathSegments("username-optional") - .build(); + .addPathSegments("username-optional"); + if (requestOptions != null) { + requestOptions.getQueryParameters().forEach((key, value) -> { + httpUrl.addQueryParameter(key, value); + }); + } RequestBody body; try { body = RequestBody.create("", null); @@ -185,7 +199,7 @@ public CompletableFuture> createUsername throw new SeedRequestParametersException("Failed to serialize request", e); } Request okhttpRequest = new Request.Builder() - .url(httpUrl) + .url(httpUrl.build()) .method("POST", body) .headers(Headers.of(clientOptions.headers(requestOptions))) .addHeader("Content-Type", "application/json") @@ -257,6 +271,11 @@ public CompletableFuture> getUsername( QueryStringMapper.addQueryParameter(httpUrl, "bigIntParam", request.getBigIntParam(), false); QueryStringMapper.addQueryParameter(httpUrl, "excludeUser", request.getExcludeUser(), true); QueryStringMapper.addQueryParameter(httpUrl, "filter", request.getFilter(), true); + if (requestOptions != null) { + requestOptions.getQueryParameters().forEach((key, value) -> { + httpUrl.addQueryParameter(key, value); + }); + } Request.Builder _requestBuilder = new Request.Builder() .url(httpUrl.build()) .method("GET", null) diff --git a/seed/java-sdk/request-parameters/src/main/java/com/seed/requestParameters/resources/user/RawUserClient.java b/seed/java-sdk/request-parameters/src/main/java/com/seed/requestParameters/resources/user/RawUserClient.java index 7d5a9c94b3ad..54013f64f7c6 100644 --- a/seed/java-sdk/request-parameters/src/main/java/com/seed/requestParameters/resources/user/RawUserClient.java +++ b/seed/java-sdk/request-parameters/src/main/java/com/seed/requestParameters/resources/user/RawUserClient.java @@ -45,6 +45,11 @@ public SeedRequestParametersHttpResponse createUsername( .addPathSegments("user") .addPathSegments("username"); QueryStringMapper.addQueryParameter(httpUrl, "tags", request.getTags(), false); + if (requestOptions != null) { + requestOptions.getQueryParameters().forEach((key, value) -> { + httpUrl.addQueryParameter(key, value); + }); + } RequestBody body; try { body = RequestBody.create( @@ -88,6 +93,11 @@ public SeedRequestParametersHttpResponse createUsernameWithReferencedType( .addPathSegments("user") .addPathSegments("username-referenced"); QueryStringMapper.addQueryParameter(httpUrl, "tags", request.getTags(), false); + if (requestOptions != null) { + requestOptions.getQueryParameters().forEach((key, value) -> { + httpUrl.addQueryParameter(key, value); + }); + } RequestBody body; try { body = RequestBody.create( @@ -134,11 +144,15 @@ public SeedRequestParametersHttpResponse createUsernameOptional( public SeedRequestParametersHttpResponse createUsernameOptional( Optional request, RequestOptions requestOptions) { - HttpUrl httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) + HttpUrl.Builder httpUrl = HttpUrl.parse(this.clientOptions.environment().getUrl()) .newBuilder() .addPathSegments("user") - .addPathSegments("username-optional") - .build(); + .addPathSegments("username-optional"); + if (requestOptions != null) { + requestOptions.getQueryParameters().forEach((key, value) -> { + httpUrl.addQueryParameter(key, value); + }); + } RequestBody body; try { body = RequestBody.create("", null); @@ -150,7 +164,7 @@ public SeedRequestParametersHttpResponse createUsernameOptional( throw new SeedRequestParametersException("Failed to serialize request", e); } Request okhttpRequest = new Request.Builder() - .url(httpUrl) + .url(httpUrl.build()) .method("POST", body) .headers(Headers.of(clientOptions.headers(requestOptions))) .addHeader("Content-Type", "application/json") @@ -206,6 +220,11 @@ public SeedRequestParametersHttpResponse getUsername(GetUsersRequest reque QueryStringMapper.addQueryParameter(httpUrl, "bigIntParam", request.getBigIntParam(), false); QueryStringMapper.addQueryParameter(httpUrl, "excludeUser", request.getExcludeUser(), true); QueryStringMapper.addQueryParameter(httpUrl, "filter", request.getFilter(), true); + if (requestOptions != null) { + requestOptions.getQueryParameters().forEach((key, value) -> { + httpUrl.addQueryParameter(key, value); + }); + } Request.Builder _requestBuilder = new Request.Builder() .url(httpUrl.build()) .method("GET", null) diff --git a/seed/ruby-sdk-v2/accept-header/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/accept-header/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/accept-header/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/accept-header/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/accept-header/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/accept-header/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/accept-header/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/accept-header/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/alias-extends/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/alias-extends/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/alias-extends/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/alias-extends/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/alias-extends/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/alias-extends/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/alias-extends/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/alias-extends/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/alias/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/alias/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/alias/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/alias/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/alias/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/alias/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/alias/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/alias/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/any-auth/README.md b/seed/ruby-sdk-v2/any-auth/README.md index 030d61cd41fd..f0085f5bfc63 100644 --- a/seed/ruby-sdk-v2/any-auth/README.md +++ b/seed/ruby-sdk-v2/any-auth/README.md @@ -11,7 +11,10 @@ The Seed Ruby library provides convenient access to the Seed APIs from Ruby. - [Environments](#environments) - [Errors](#errors) - [Advanced](#advanced) + - [Retries](#retries) - [Timeouts](#timeouts) + - [Additional Headers](#additional-headers) + - [Additional Query Parameters](#additional-query-parameters) - [Contributing](#contributing) ## Reference @@ -76,6 +79,28 @@ end ## Advanced +### Retries + +The SDK is instrumented with automatic retries. A request will be retried as long as the request is deemed +retryable and the number of retry attempts has not grown larger than the configured retry limit (default: 2). + +A request is deemed retryable when any of the following HTTP status codes is returned: + +- [408](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) (Timeout) +- [429](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) (Too Many Requests) +- [5XX](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500) (Internal Server Errors) + +Use the `max_retries` option to configure this behavior. + +```ruby +require "seed" + +client = Seed::Client.new( + base_url: "https://example.com", + max_retries: 3 # Configure max retries (default is 2) +) +``` + ### Timeouts The SDK defaults to a 60 second timeout. Use the `timeout` option to configure this behavior. @@ -89,6 +114,40 @@ response = client.auth.get_token( ) ``` +### Additional Headers + +If you would like to send additional headers as part of the request, use the `additional_headers` request option. + +```ruby +require "seed" + +response = client.auth.get_token( + ..., + request_options: { + additional_headers: { + "X-Custom-Header" => "custom-value" + } + } +) +``` + +### Additional Query Parameters + +If you would like to send additional query parameters as part of the request, use the `additional_query_parameters` request option. + +```ruby +require "seed" + +response = client.auth.get_token( + ..., + request_options: { + additional_query_parameters: { + "custom_param" => "custom-value" + } + } +) +``` + ## Contributing While we value open-source contributions to this SDK, this library is generated programmatically. diff --git a/seed/ruby-sdk-v2/any-auth/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/any-auth/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/any-auth/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/any-auth/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/any-auth/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/any-auth/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/any-auth/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/any-auth/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/api-wide-base-path/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/api-wide-base-path/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/api-wide-base-path/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/api-wide-base-path/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/api-wide-base-path/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/api-wide-base-path/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/api-wide-base-path/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/api-wide-base-path/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/audiences/README.md b/seed/ruby-sdk-v2/audiences/README.md index c7fea002800b..5c7ba0181fc6 100644 --- a/seed/ruby-sdk-v2/audiences/README.md +++ b/seed/ruby-sdk-v2/audiences/README.md @@ -11,7 +11,10 @@ The Seed Ruby library provides convenient access to the Seed APIs from Ruby. - [Environments](#environments) - [Errors](#errors) - [Advanced](#advanced) + - [Retries](#retries) - [Timeouts](#timeouts) + - [Additional Headers](#additional-headers) + - [Additional Query Parameters](#additional-query-parameters) - [Contributing](#contributing) ## Reference @@ -83,6 +86,28 @@ end ## Advanced +### Retries + +The SDK is instrumented with automatic retries. A request will be retried as long as the request is deemed +retryable and the number of retry attempts has not grown larger than the configured retry limit (default: 2). + +A request is deemed retryable when any of the following HTTP status codes is returned: + +- [408](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) (Timeout) +- [429](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) (Too Many Requests) +- [5XX](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500) (Internal Server Errors) + +Use the `max_retries` option to configure this behavior. + +```ruby +require "seed" + +client = Seed::Client.new( + base_url: "https://example.com", + max_retries: 3 # Configure max retries (default is 2) +) +``` + ### Timeouts The SDK defaults to a 60 second timeout. Use the `timeout` option to configure this behavior. @@ -96,6 +121,40 @@ response = client.foo.find( ) ``` +### Additional Headers + +If you would like to send additional headers as part of the request, use the `additional_headers` request option. + +```ruby +require "seed" + +response = client.foo.find( + ..., + request_options: { + additional_headers: { + "X-Custom-Header" => "custom-value" + } + } +) +``` + +### Additional Query Parameters + +If you would like to send additional query parameters as part of the request, use the `additional_query_parameters` request option. + +```ruby +require "seed" + +response = client.foo.find( + ..., + request_options: { + additional_query_parameters: { + "custom_param" => "custom-value" + } + } +) +``` + ## Contributing While we value open-source contributions to this SDK, this library is generated programmatically. diff --git a/seed/ruby-sdk-v2/audiences/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/audiences/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/audiences/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/audiences/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/audiences/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/audiences/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/audiences/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/audiences/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/basic-auth-environment-variables/README.md b/seed/ruby-sdk-v2/basic-auth-environment-variables/README.md index 3877254ac162..808cdce1c91a 100644 --- a/seed/ruby-sdk-v2/basic-auth-environment-variables/README.md +++ b/seed/ruby-sdk-v2/basic-auth-environment-variables/README.md @@ -11,7 +11,10 @@ The Seed Ruby library provides convenient access to the Seed APIs from Ruby. - [Environments](#environments) - [Errors](#errors) - [Advanced](#advanced) + - [Retries](#retries) - [Timeouts](#timeouts) + - [Additional Headers](#additional-headers) + - [Additional Query Parameters](#additional-query-parameters) - [Contributing](#contributing) ## Reference @@ -74,6 +77,28 @@ end ## Advanced +### Retries + +The SDK is instrumented with automatic retries. A request will be retried as long as the request is deemed +retryable and the number of retry attempts has not grown larger than the configured retry limit (default: 2). + +A request is deemed retryable when any of the following HTTP status codes is returned: + +- [408](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) (Timeout) +- [429](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) (Too Many Requests) +- [5XX](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500) (Internal Server Errors) + +Use the `max_retries` option to configure this behavior. + +```ruby +require "seed" + +client = Seed::Client.new( + base_url: "https://example.com", + max_retries: 3 # Configure max retries (default is 2) +) +``` + ### Timeouts The SDK defaults to a 60 second timeout. Use the `timeout` option to configure this behavior. @@ -87,6 +112,40 @@ response = client.basic_auth.post_with_basic_auth( ) ``` +### Additional Headers + +If you would like to send additional headers as part of the request, use the `additional_headers` request option. + +```ruby +require "seed" + +response = client.basic_auth.post_with_basic_auth( + ..., + request_options: { + additional_headers: { + "X-Custom-Header" => "custom-value" + } + } +) +``` + +### Additional Query Parameters + +If you would like to send additional query parameters as part of the request, use the `additional_query_parameters` request option. + +```ruby +require "seed" + +response = client.basic_auth.post_with_basic_auth( + ..., + request_options: { + additional_query_parameters: { + "custom_param" => "custom-value" + } + } +) +``` + ## Contributing While we value open-source contributions to this SDK, this library is generated programmatically. diff --git a/seed/ruby-sdk-v2/basic-auth-environment-variables/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/basic-auth-environment-variables/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/basic-auth-environment-variables/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/basic-auth-environment-variables/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/basic-auth-environment-variables/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/basic-auth-environment-variables/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/basic-auth-environment-variables/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/basic-auth-environment-variables/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/basic-auth/README.md b/seed/ruby-sdk-v2/basic-auth/README.md index 7e0de8ac056b..3ef5b5d8dee0 100644 --- a/seed/ruby-sdk-v2/basic-auth/README.md +++ b/seed/ruby-sdk-v2/basic-auth/README.md @@ -11,7 +11,10 @@ The Seed Ruby library provides convenient access to the Seed APIs from Ruby. - [Environments](#environments) - [Errors](#errors) - [Advanced](#advanced) + - [Retries](#retries) - [Timeouts](#timeouts) + - [Additional Headers](#additional-headers) + - [Additional Query Parameters](#additional-query-parameters) - [Contributing](#contributing) ## Reference @@ -74,6 +77,28 @@ end ## Advanced +### Retries + +The SDK is instrumented with automatic retries. A request will be retried as long as the request is deemed +retryable and the number of retry attempts has not grown larger than the configured retry limit (default: 2). + +A request is deemed retryable when any of the following HTTP status codes is returned: + +- [408](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) (Timeout) +- [429](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) (Too Many Requests) +- [5XX](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500) (Internal Server Errors) + +Use the `max_retries` option to configure this behavior. + +```ruby +require "seed" + +client = Seed::Client.new( + base_url: "https://example.com", + max_retries: 3 # Configure max retries (default is 2) +) +``` + ### Timeouts The SDK defaults to a 60 second timeout. Use the `timeout` option to configure this behavior. @@ -87,6 +112,40 @@ response = client.basic_auth.post_with_basic_auth( ) ``` +### Additional Headers + +If you would like to send additional headers as part of the request, use the `additional_headers` request option. + +```ruby +require "seed" + +response = client.basic_auth.post_with_basic_auth( + ..., + request_options: { + additional_headers: { + "X-Custom-Header" => "custom-value" + } + } +) +``` + +### Additional Query Parameters + +If you would like to send additional query parameters as part of the request, use the `additional_query_parameters` request option. + +```ruby +require "seed" + +response = client.basic_auth.post_with_basic_auth( + ..., + request_options: { + additional_query_parameters: { + "custom_param" => "custom-value" + } + } +) +``` + ## Contributing While we value open-source contributions to this SDK, this library is generated programmatically. diff --git a/seed/ruby-sdk-v2/basic-auth/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/basic-auth/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/basic-auth/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/basic-auth/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/basic-auth/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/basic-auth/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/basic-auth/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/basic-auth/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/bearer-token-environment-variable/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/bearer-token-environment-variable/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/bearer-token-environment-variable/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/bearer-token-environment-variable/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/bearer-token-environment-variable/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/bearer-token-environment-variable/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/bearer-token-environment-variable/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/bearer-token-environment-variable/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/bytes-download/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/bytes-download/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/bytes-download/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/bytes-download/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/bytes-download/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/bytes-download/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/bytes-download/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/bytes-download/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/bytes-upload/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/bytes-upload/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/bytes-upload/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/bytes-upload/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/bytes-upload/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/bytes-upload/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/bytes-upload/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/bytes-upload/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/circular-references-advanced/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/circular-references-advanced/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/circular-references-advanced/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/circular-references-advanced/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/circular-references-advanced/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/circular-references-advanced/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/circular-references-advanced/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/circular-references-advanced/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/circular-references/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/circular-references/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/circular-references/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/circular-references/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/circular-references/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/circular-references/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/circular-references/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/circular-references/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/client-side-params/README.md b/seed/ruby-sdk-v2/client-side-params/README.md index 7f7eb5c9c7fa..70d73fc2b68c 100644 --- a/seed/ruby-sdk-v2/client-side-params/README.md +++ b/seed/ruby-sdk-v2/client-side-params/README.md @@ -11,7 +11,10 @@ The Seed Ruby library provides convenient access to the Seed APIs from Ruby. - [Environments](#environments) - [Errors](#errors) - [Advanced](#advanced) + - [Retries](#retries) - [Timeouts](#timeouts) + - [Additional Headers](#additional-headers) + - [Additional Query Parameters](#additional-query-parameters) - [Contributing](#contributing) ## Reference @@ -76,6 +79,28 @@ end ## Advanced +### Retries + +The SDK is instrumented with automatic retries. A request will be retried as long as the request is deemed +retryable and the number of retry attempts has not grown larger than the configured retry limit (default: 2). + +A request is deemed retryable when any of the following HTTP status codes is returned: + +- [408](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) (Timeout) +- [429](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) (Too Many Requests) +- [5XX](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500) (Internal Server Errors) + +Use the `max_retries` option to configure this behavior. + +```ruby +require "seed" + +client = Seed::Client.new( + base_url: "https://example.com", + max_retries: 3 # Configure max retries (default is 2) +) +``` + ### Timeouts The SDK defaults to a 60 second timeout. Use the `timeout` option to configure this behavior. @@ -89,6 +114,40 @@ response = client.service.search_resources( ) ``` +### Additional Headers + +If you would like to send additional headers as part of the request, use the `additional_headers` request option. + +```ruby +require "seed" + +response = client.service.search_resources( + ..., + request_options: { + additional_headers: { + "X-Custom-Header" => "custom-value" + } + } +) +``` + +### Additional Query Parameters + +If you would like to send additional query parameters as part of the request, use the `additional_query_parameters` request option. + +```ruby +require "seed" + +response = client.service.search_resources( + ..., + request_options: { + additional_query_parameters: { + "custom_param" => "custom-value" + } + } +) +``` + ## Contributing While we value open-source contributions to this SDK, this library is generated programmatically. diff --git a/seed/ruby-sdk-v2/client-side-params/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/client-side-params/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/client-side-params/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/client-side-params/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/client-side-params/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/client-side-params/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/client-side-params/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/client-side-params/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/content-type/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/content-type/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/content-type/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/content-type/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/content-type/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/content-type/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/content-type/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/content-type/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/cross-package-type-names/README.md b/seed/ruby-sdk-v2/cross-package-type-names/README.md index 1860b78c4c37..47a50afd283d 100644 --- a/seed/ruby-sdk-v2/cross-package-type-names/README.md +++ b/seed/ruby-sdk-v2/cross-package-type-names/README.md @@ -11,7 +11,10 @@ The Seed Ruby library provides convenient access to the Seed APIs from Ruby. - [Environments](#environments) - [Errors](#errors) - [Advanced](#advanced) + - [Retries](#retries) - [Timeouts](#timeouts) + - [Additional Headers](#additional-headers) + - [Additional Query Parameters](#additional-query-parameters) - [Contributing](#contributing) ## Reference @@ -75,6 +78,28 @@ end ## Advanced +### Retries + +The SDK is instrumented with automatic retries. A request will be retried as long as the request is deemed +retryable and the number of retry attempts has not grown larger than the configured retry limit (default: 2). + +A request is deemed retryable when any of the following HTTP status codes is returned: + +- [408](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) (Timeout) +- [429](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) (Too Many Requests) +- [5XX](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500) (Internal Server Errors) + +Use the `max_retries` option to configure this behavior. + +```ruby +require "seed" + +client = Seed::Client.new( + base_url: "https://example.com", + max_retries: 3 # Configure max retries (default is 2) +) +``` + ### Timeouts The SDK defaults to a 60 second timeout. Use the `timeout` option to configure this behavior. @@ -88,6 +113,40 @@ response = client.foo.find( ) ``` +### Additional Headers + +If you would like to send additional headers as part of the request, use the `additional_headers` request option. + +```ruby +require "seed" + +response = client.foo.find( + ..., + request_options: { + additional_headers: { + "X-Custom-Header" => "custom-value" + } + } +) +``` + +### Additional Query Parameters + +If you would like to send additional query parameters as part of the request, use the `additional_query_parameters` request option. + +```ruby +require "seed" + +response = client.foo.find( + ..., + request_options: { + additional_query_parameters: { + "custom_param" => "custom-value" + } + } +) +``` + ## Contributing While we value open-source contributions to this SDK, this library is generated programmatically. diff --git a/seed/ruby-sdk-v2/cross-package-type-names/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/cross-package-type-names/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/cross-package-type-names/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/cross-package-type-names/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/cross-package-type-names/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/cross-package-type-names/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/cross-package-type-names/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/cross-package-type-names/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/dollar-string-examples/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/dollar-string-examples/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/dollar-string-examples/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/dollar-string-examples/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/dollar-string-examples/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/dollar-string-examples/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/dollar-string-examples/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/dollar-string-examples/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/empty-clients/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/empty-clients/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/empty-clients/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/empty-clients/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/empty-clients/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/empty-clients/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/empty-clients/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/empty-clients/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/endpoint-security-auth/README.md b/seed/ruby-sdk-v2/endpoint-security-auth/README.md index 030d61cd41fd..f0085f5bfc63 100644 --- a/seed/ruby-sdk-v2/endpoint-security-auth/README.md +++ b/seed/ruby-sdk-v2/endpoint-security-auth/README.md @@ -11,7 +11,10 @@ The Seed Ruby library provides convenient access to the Seed APIs from Ruby. - [Environments](#environments) - [Errors](#errors) - [Advanced](#advanced) + - [Retries](#retries) - [Timeouts](#timeouts) + - [Additional Headers](#additional-headers) + - [Additional Query Parameters](#additional-query-parameters) - [Contributing](#contributing) ## Reference @@ -76,6 +79,28 @@ end ## Advanced +### Retries + +The SDK is instrumented with automatic retries. A request will be retried as long as the request is deemed +retryable and the number of retry attempts has not grown larger than the configured retry limit (default: 2). + +A request is deemed retryable when any of the following HTTP status codes is returned: + +- [408](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) (Timeout) +- [429](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) (Too Many Requests) +- [5XX](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500) (Internal Server Errors) + +Use the `max_retries` option to configure this behavior. + +```ruby +require "seed" + +client = Seed::Client.new( + base_url: "https://example.com", + max_retries: 3 # Configure max retries (default is 2) +) +``` + ### Timeouts The SDK defaults to a 60 second timeout. Use the `timeout` option to configure this behavior. @@ -89,6 +114,40 @@ response = client.auth.get_token( ) ``` +### Additional Headers + +If you would like to send additional headers as part of the request, use the `additional_headers` request option. + +```ruby +require "seed" + +response = client.auth.get_token( + ..., + request_options: { + additional_headers: { + "X-Custom-Header" => "custom-value" + } + } +) +``` + +### Additional Query Parameters + +If you would like to send additional query parameters as part of the request, use the `additional_query_parameters` request option. + +```ruby +require "seed" + +response = client.auth.get_token( + ..., + request_options: { + additional_query_parameters: { + "custom_param" => "custom-value" + } + } +) +``` + ## Contributing While we value open-source contributions to this SDK, this library is generated programmatically. diff --git a/seed/ruby-sdk-v2/endpoint-security-auth/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/endpoint-security-auth/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/endpoint-security-auth/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/endpoint-security-auth/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/endpoint-security-auth/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/endpoint-security-auth/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/endpoint-security-auth/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/endpoint-security-auth/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/enum/README.md b/seed/ruby-sdk-v2/enum/README.md index a4af74cc4472..001c854c5086 100644 --- a/seed/ruby-sdk-v2/enum/README.md +++ b/seed/ruby-sdk-v2/enum/README.md @@ -11,7 +11,10 @@ The Seed Ruby library provides convenient access to the Seed APIs from Ruby. - [Environments](#environments) - [Errors](#errors) - [Advanced](#advanced) + - [Retries](#retries) - [Timeouts](#timeouts) + - [Additional Headers](#additional-headers) + - [Additional Query Parameters](#additional-query-parameters) - [Contributing](#contributing) ## Reference @@ -75,6 +78,28 @@ end ## Advanced +### Retries + +The SDK is instrumented with automatic retries. A request will be retried as long as the request is deemed +retryable and the number of retry attempts has not grown larger than the configured retry limit (default: 2). + +A request is deemed retryable when any of the following HTTP status codes is returned: + +- [408](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) (Timeout) +- [429](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) (Too Many Requests) +- [5XX](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500) (Internal Server Errors) + +Use the `max_retries` option to configure this behavior. + +```ruby +require "seed" + +client = Seed::Client.new( + base_url: "https://example.com", + max_retries: 3 # Configure max retries (default is 2) +) +``` + ### Timeouts The SDK defaults to a 60 second timeout. Use the `timeout` option to configure this behavior. @@ -88,6 +113,40 @@ response = client.headers.send_( ) ``` +### Additional Headers + +If you would like to send additional headers as part of the request, use the `additional_headers` request option. + +```ruby +require "seed" + +response = client.headers.send_( + ..., + request_options: { + additional_headers: { + "X-Custom-Header" => "custom-value" + } + } +) +``` + +### Additional Query Parameters + +If you would like to send additional query parameters as part of the request, use the `additional_query_parameters` request option. + +```ruby +require "seed" + +response = client.headers.send_( + ..., + request_options: { + additional_query_parameters: { + "custom_param" => "custom-value" + } + } +) +``` + ## Contributing While we value open-source contributions to this SDK, this library is generated programmatically. diff --git a/seed/ruby-sdk-v2/enum/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/enum/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/enum/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/enum/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/enum/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/enum/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/enum/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/enum/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/error-property/README.md b/seed/ruby-sdk-v2/error-property/README.md index 107fbb858878..6765fdb700db 100644 --- a/seed/ruby-sdk-v2/error-property/README.md +++ b/seed/ruby-sdk-v2/error-property/README.md @@ -11,7 +11,10 @@ The Seed Ruby library provides convenient access to the Seed APIs from Ruby. - [Environments](#environments) - [Errors](#errors) - [Advanced](#advanced) + - [Retries](#retries) - [Timeouts](#timeouts) + - [Additional Headers](#additional-headers) + - [Additional Query Parameters](#additional-query-parameters) - [Contributing](#contributing) ## Reference @@ -71,6 +74,28 @@ end ## Advanced +### Retries + +The SDK is instrumented with automatic retries. A request will be retried as long as the request is deemed +retryable and the number of retry attempts has not grown larger than the configured retry limit (default: 2). + +A request is deemed retryable when any of the following HTTP status codes is returned: + +- [408](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) (Timeout) +- [429](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) (Too Many Requests) +- [5XX](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500) (Internal Server Errors) + +Use the `max_retries` option to configure this behavior. + +```ruby +require "seed" + +client = Seed::Client.new( + base_url: "https://example.com", + max_retries: 3 # Configure max retries (default is 2) +) +``` + ### Timeouts The SDK defaults to a 60 second timeout. Use the `timeout` option to configure this behavior. @@ -84,6 +109,40 @@ response = client.property_based_error.throw_error( ) ``` +### Additional Headers + +If you would like to send additional headers as part of the request, use the `additional_headers` request option. + +```ruby +require "seed" + +response = client.property_based_error.throw_error( + ..., + request_options: { + additional_headers: { + "X-Custom-Header" => "custom-value" + } + } +) +``` + +### Additional Query Parameters + +If you would like to send additional query parameters as part of the request, use the `additional_query_parameters` request option. + +```ruby +require "seed" + +response = client.property_based_error.throw_error( + ..., + request_options: { + additional_query_parameters: { + "custom_param" => "custom-value" + } + } +) +``` + ## Contributing While we value open-source contributions to this SDK, this library is generated programmatically. diff --git a/seed/ruby-sdk-v2/error-property/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/error-property/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/error-property/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/error-property/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/error-property/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/error-property/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/error-property/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/error-property/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/errors/README.md b/seed/ruby-sdk-v2/errors/README.md index 648d39e9de5d..b3f50797d6f3 100644 --- a/seed/ruby-sdk-v2/errors/README.md +++ b/seed/ruby-sdk-v2/errors/README.md @@ -11,7 +11,10 @@ The Seed Ruby library provides convenient access to the Seed APIs from Ruby. - [Environments](#environments) - [Errors](#errors) - [Advanced](#advanced) + - [Retries](#retries) - [Timeouts](#timeouts) + - [Additional Headers](#additional-headers) + - [Additional Query Parameters](#additional-query-parameters) - [Contributing](#contributing) ## Reference @@ -71,6 +74,28 @@ end ## Advanced +### Retries + +The SDK is instrumented with automatic retries. A request will be retried as long as the request is deemed +retryable and the number of retry attempts has not grown larger than the configured retry limit (default: 2). + +A request is deemed retryable when any of the following HTTP status codes is returned: + +- [408](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) (Timeout) +- [429](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) (Too Many Requests) +- [5XX](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500) (Internal Server Errors) + +Use the `max_retries` option to configure this behavior. + +```ruby +require "seed" + +client = Seed::Client.new( + base_url: "https://example.com", + max_retries: 3 # Configure max retries (default is 2) +) +``` + ### Timeouts The SDK defaults to a 60 second timeout. Use the `timeout` option to configure this behavior. @@ -84,6 +109,40 @@ response = client.simple.foo_without_endpoint_error( ) ``` +### Additional Headers + +If you would like to send additional headers as part of the request, use the `additional_headers` request option. + +```ruby +require "seed" + +response = client.simple.foo_without_endpoint_error( + ..., + request_options: { + additional_headers: { + "X-Custom-Header" => "custom-value" + } + } +) +``` + +### Additional Query Parameters + +If you would like to send additional query parameters as part of the request, use the `additional_query_parameters` request option. + +```ruby +require "seed" + +response = client.simple.foo_without_endpoint_error( + ..., + request_options: { + additional_query_parameters: { + "custom_param" => "custom-value" + } + } +) +``` + ## Contributing While we value open-source contributions to this SDK, this library is generated programmatically. diff --git a/seed/ruby-sdk-v2/errors/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/errors/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/errors/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/errors/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/errors/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/errors/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/errors/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/errors/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/examples/no-custom-config/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/examples/no-custom-config/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/examples/no-custom-config/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/examples/no-custom-config/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/examples/no-custom-config/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/examples/no-custom-config/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/examples/no-custom-config/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/examples/no-custom-config/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/examples/readme-config/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/examples/readme-config/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/examples/readme-config/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/examples/readme-config/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/examples/readme-config/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/examples/readme-config/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/examples/readme-config/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/examples/readme-config/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/examples/require-paths/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/examples/require-paths/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/examples/require-paths/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/examples/require-paths/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/examples/require-paths/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/examples/require-paths/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/examples/require-paths/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/examples/require-paths/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/examples/wire-tests/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/examples/wire-tests/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/examples/wire-tests/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/examples/wire-tests/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/examples/wire-tests/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/examples/wire-tests/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/examples/wire-tests/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/examples/wire-tests/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/exhaustive/wire-tests/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/exhaustive/wire-tests/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/exhaustive/wire-tests/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/exhaustive/wire-tests/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/exhaustive/wire-tests/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/exhaustive/wire-tests/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/exhaustive/wire-tests/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/exhaustive/wire-tests/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/extends/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/extends/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/extends/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/extends/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/extends/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/extends/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/extends/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/extends/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/extra-properties/README.md b/seed/ruby-sdk-v2/extra-properties/README.md index c8a373f34571..5db6deede4af 100644 --- a/seed/ruby-sdk-v2/extra-properties/README.md +++ b/seed/ruby-sdk-v2/extra-properties/README.md @@ -11,7 +11,10 @@ The Seed Ruby library provides convenient access to the Seed APIs from Ruby. - [Environments](#environments) - [Errors](#errors) - [Advanced](#advanced) + - [Retries](#retries) - [Timeouts](#timeouts) + - [Additional Headers](#additional-headers) + - [Additional Query Parameters](#additional-query-parameters) - [Contributing](#contributing) ## Reference @@ -75,6 +78,28 @@ end ## Advanced +### Retries + +The SDK is instrumented with automatic retries. A request will be retried as long as the request is deemed +retryable and the number of retry attempts has not grown larger than the configured retry limit (default: 2). + +A request is deemed retryable when any of the following HTTP status codes is returned: + +- [408](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) (Timeout) +- [429](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) (Too Many Requests) +- [5XX](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500) (Internal Server Errors) + +Use the `max_retries` option to configure this behavior. + +```ruby +require "seed" + +client = Seed::Client.new( + base_url: "https://example.com", + max_retries: 3 # Configure max retries (default is 2) +) +``` + ### Timeouts The SDK defaults to a 60 second timeout. Use the `timeout` option to configure this behavior. @@ -88,6 +113,40 @@ response = client.user.create_user( ) ``` +### Additional Headers + +If you would like to send additional headers as part of the request, use the `additional_headers` request option. + +```ruby +require "seed" + +response = client.user.create_user( + ..., + request_options: { + additional_headers: { + "X-Custom-Header" => "custom-value" + } + } +) +``` + +### Additional Query Parameters + +If you would like to send additional query parameters as part of the request, use the `additional_query_parameters` request option. + +```ruby +require "seed" + +response = client.user.create_user( + ..., + request_options: { + additional_query_parameters: { + "custom_param" => "custom-value" + } + } +) +``` + ## Contributing While we value open-source contributions to this SDK, this library is generated programmatically. diff --git a/seed/ruby-sdk-v2/extra-properties/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/extra-properties/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/extra-properties/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/extra-properties/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/extra-properties/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/extra-properties/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/extra-properties/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/extra-properties/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/file-download/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/file-download/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/file-download/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/file-download/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/file-download/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/file-download/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/file-download/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/file-download/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/file-upload-openapi/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/file-upload-openapi/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/file-upload-openapi/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/file-upload-openapi/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/file-upload-openapi/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/file-upload-openapi/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/file-upload-openapi/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/file-upload-openapi/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/file-upload/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/file-upload/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/file-upload/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/file-upload/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/file-upload/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/file-upload/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/file-upload/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/file-upload/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/folders/README.md b/seed/ruby-sdk-v2/folders/README.md index 1e69ba71b832..4bbe619b65a1 100644 --- a/seed/ruby-sdk-v2/folders/README.md +++ b/seed/ruby-sdk-v2/folders/README.md @@ -11,7 +11,10 @@ The Seed Ruby library provides convenient access to the Seed APIs from Ruby. - [Environments](#environments) - [Errors](#errors) - [Advanced](#advanced) + - [Retries](#retries) - [Timeouts](#timeouts) + - [Additional Headers](#additional-headers) + - [Additional Query Parameters](#additional-query-parameters) - [Contributing](#contributing) ## Reference @@ -71,6 +74,28 @@ end ## Advanced +### Retries + +The SDK is instrumented with automatic retries. A request will be retried as long as the request is deemed +retryable and the number of retry attempts has not grown larger than the configured retry limit (default: 2). + +A request is deemed retryable when any of the following HTTP status codes is returned: + +- [408](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) (Timeout) +- [429](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) (Too Many Requests) +- [5XX](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500) (Internal Server Errors) + +Use the `max_retries` option to configure this behavior. + +```ruby +require "seed" + +client = Seed::Client.new( + base_url: "https://example.com", + max_retries: 3 # Configure max retries (default is 2) +) +``` + ### Timeouts The SDK defaults to a 60 second timeout. Use the `timeout` option to configure this behavior. @@ -84,6 +109,40 @@ response = client.foo( ) ``` +### Additional Headers + +If you would like to send additional headers as part of the request, use the `additional_headers` request option. + +```ruby +require "seed" + +response = client.foo( + ..., + request_options: { + additional_headers: { + "X-Custom-Header" => "custom-value" + } + } +) +``` + +### Additional Query Parameters + +If you would like to send additional query parameters as part of the request, use the `additional_query_parameters` request option. + +```ruby +require "seed" + +response = client.foo( + ..., + request_options: { + additional_query_parameters: { + "custom_param" => "custom-value" + } + } +) +``` + ## Contributing While we value open-source contributions to this SDK, this library is generated programmatically. diff --git a/seed/ruby-sdk-v2/folders/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/folders/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/folders/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/folders/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/folders/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/folders/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/folders/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/folders/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/header-auth-environment-variable/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/header-auth-environment-variable/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/header-auth-environment-variable/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/header-auth-environment-variable/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/header-auth-environment-variable/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/header-auth-environment-variable/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/header-auth-environment-variable/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/header-auth-environment-variable/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/header-auth/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/header-auth/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/header-auth/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/header-auth/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/header-auth/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/header-auth/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/header-auth/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/header-auth/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/http-head/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/http-head/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/http-head/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/http-head/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/http-head/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/http-head/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/http-head/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/http-head/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/idempotency-headers/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/idempotency-headers/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/idempotency-headers/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/idempotency-headers/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/idempotency-headers/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/idempotency-headers/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/idempotency-headers/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/idempotency-headers/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/inferred-auth-explicit/README.md b/seed/ruby-sdk-v2/inferred-auth-explicit/README.md index ec0ba71d7d76..b62feca344b9 100644 --- a/seed/ruby-sdk-v2/inferred-auth-explicit/README.md +++ b/seed/ruby-sdk-v2/inferred-auth-explicit/README.md @@ -11,7 +11,10 @@ The Seed Ruby library provides convenient access to the Seed APIs from Ruby. - [Environments](#environments) - [Errors](#errors) - [Advanced](#advanced) + - [Retries](#retries) - [Timeouts](#timeouts) + - [Additional Headers](#additional-headers) + - [Additional Query Parameters](#additional-query-parameters) - [Contributing](#contributing) ## Reference @@ -78,6 +81,28 @@ end ## Advanced +### Retries + +The SDK is instrumented with automatic retries. A request will be retried as long as the request is deemed +retryable and the number of retry attempts has not grown larger than the configured retry limit (default: 2). + +A request is deemed retryable when any of the following HTTP status codes is returned: + +- [408](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) (Timeout) +- [429](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) (Too Many Requests) +- [5XX](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500) (Internal Server Errors) + +Use the `max_retries` option to configure this behavior. + +```ruby +require "seed" + +client = Seed::Client.new( + base_url: "https://example.com", + max_retries: 3 # Configure max retries (default is 2) +) +``` + ### Timeouts The SDK defaults to a 60 second timeout. Use the `timeout` option to configure this behavior. @@ -91,6 +116,40 @@ response = client.auth.get_token_with_client_credentials( ) ``` +### Additional Headers + +If you would like to send additional headers as part of the request, use the `additional_headers` request option. + +```ruby +require "seed" + +response = client.auth.get_token_with_client_credentials( + ..., + request_options: { + additional_headers: { + "X-Custom-Header" => "custom-value" + } + } +) +``` + +### Additional Query Parameters + +If you would like to send additional query parameters as part of the request, use the `additional_query_parameters` request option. + +```ruby +require "seed" + +response = client.auth.get_token_with_client_credentials( + ..., + request_options: { + additional_query_parameters: { + "custom_param" => "custom-value" + } + } +) +``` + ## Contributing While we value open-source contributions to this SDK, this library is generated programmatically. diff --git a/seed/ruby-sdk-v2/inferred-auth-explicit/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/inferred-auth-explicit/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/inferred-auth-explicit/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/inferred-auth-explicit/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/inferred-auth-explicit/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/inferred-auth-explicit/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/inferred-auth-explicit/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/inferred-auth-explicit/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/inferred-auth-implicit-api-key/README.md b/seed/ruby-sdk-v2/inferred-auth-implicit-api-key/README.md index 107649607431..27902332a219 100644 --- a/seed/ruby-sdk-v2/inferred-auth-implicit-api-key/README.md +++ b/seed/ruby-sdk-v2/inferred-auth-implicit-api-key/README.md @@ -11,7 +11,10 @@ The Seed Ruby library provides convenient access to the Seed APIs from Ruby. - [Environments](#environments) - [Errors](#errors) - [Advanced](#advanced) + - [Retries](#retries) - [Timeouts](#timeouts) + - [Additional Headers](#additional-headers) + - [Additional Query Parameters](#additional-query-parameters) - [Contributing](#contributing) ## Reference @@ -71,6 +74,28 @@ end ## Advanced +### Retries + +The SDK is instrumented with automatic retries. A request will be retried as long as the request is deemed +retryable and the number of retry attempts has not grown larger than the configured retry limit (default: 2). + +A request is deemed retryable when any of the following HTTP status codes is returned: + +- [408](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) (Timeout) +- [429](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) (Too Many Requests) +- [5XX](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500) (Internal Server Errors) + +Use the `max_retries` option to configure this behavior. + +```ruby +require "seed" + +client = Seed::Client.new( + base_url: "https://example.com", + max_retries: 3 # Configure max retries (default is 2) +) +``` + ### Timeouts The SDK defaults to a 60 second timeout. Use the `timeout` option to configure this behavior. @@ -84,6 +109,40 @@ response = client.auth.get_token( ) ``` +### Additional Headers + +If you would like to send additional headers as part of the request, use the `additional_headers` request option. + +```ruby +require "seed" + +response = client.auth.get_token( + ..., + request_options: { + additional_headers: { + "X-Custom-Header" => "custom-value" + } + } +) +``` + +### Additional Query Parameters + +If you would like to send additional query parameters as part of the request, use the `additional_query_parameters` request option. + +```ruby +require "seed" + +response = client.auth.get_token( + ..., + request_options: { + additional_query_parameters: { + "custom_param" => "custom-value" + } + } +) +``` + ## Contributing While we value open-source contributions to this SDK, this library is generated programmatically. diff --git a/seed/ruby-sdk-v2/inferred-auth-implicit-api-key/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/inferred-auth-implicit-api-key/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/inferred-auth-implicit-api-key/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/inferred-auth-implicit-api-key/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/inferred-auth-implicit-api-key/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/inferred-auth-implicit-api-key/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/inferred-auth-implicit-api-key/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/inferred-auth-implicit-api-key/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/inferred-auth-implicit-no-expiry/README.md b/seed/ruby-sdk-v2/inferred-auth-implicit-no-expiry/README.md index ec0ba71d7d76..b62feca344b9 100644 --- a/seed/ruby-sdk-v2/inferred-auth-implicit-no-expiry/README.md +++ b/seed/ruby-sdk-v2/inferred-auth-implicit-no-expiry/README.md @@ -11,7 +11,10 @@ The Seed Ruby library provides convenient access to the Seed APIs from Ruby. - [Environments](#environments) - [Errors](#errors) - [Advanced](#advanced) + - [Retries](#retries) - [Timeouts](#timeouts) + - [Additional Headers](#additional-headers) + - [Additional Query Parameters](#additional-query-parameters) - [Contributing](#contributing) ## Reference @@ -78,6 +81,28 @@ end ## Advanced +### Retries + +The SDK is instrumented with automatic retries. A request will be retried as long as the request is deemed +retryable and the number of retry attempts has not grown larger than the configured retry limit (default: 2). + +A request is deemed retryable when any of the following HTTP status codes is returned: + +- [408](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) (Timeout) +- [429](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) (Too Many Requests) +- [5XX](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500) (Internal Server Errors) + +Use the `max_retries` option to configure this behavior. + +```ruby +require "seed" + +client = Seed::Client.new( + base_url: "https://example.com", + max_retries: 3 # Configure max retries (default is 2) +) +``` + ### Timeouts The SDK defaults to a 60 second timeout. Use the `timeout` option to configure this behavior. @@ -91,6 +116,40 @@ response = client.auth.get_token_with_client_credentials( ) ``` +### Additional Headers + +If you would like to send additional headers as part of the request, use the `additional_headers` request option. + +```ruby +require "seed" + +response = client.auth.get_token_with_client_credentials( + ..., + request_options: { + additional_headers: { + "X-Custom-Header" => "custom-value" + } + } +) +``` + +### Additional Query Parameters + +If you would like to send additional query parameters as part of the request, use the `additional_query_parameters` request option. + +```ruby +require "seed" + +response = client.auth.get_token_with_client_credentials( + ..., + request_options: { + additional_query_parameters: { + "custom_param" => "custom-value" + } + } +) +``` + ## Contributing While we value open-source contributions to this SDK, this library is generated programmatically. diff --git a/seed/ruby-sdk-v2/inferred-auth-implicit-no-expiry/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/inferred-auth-implicit-no-expiry/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/inferred-auth-implicit-no-expiry/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/inferred-auth-implicit-no-expiry/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/inferred-auth-implicit-no-expiry/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/inferred-auth-implicit-no-expiry/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/inferred-auth-implicit-no-expiry/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/inferred-auth-implicit-no-expiry/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/inferred-auth-implicit-reference/README.md b/seed/ruby-sdk-v2/inferred-auth-implicit-reference/README.md index 6eee82779c6d..f4064175046a 100644 --- a/seed/ruby-sdk-v2/inferred-auth-implicit-reference/README.md +++ b/seed/ruby-sdk-v2/inferred-auth-implicit-reference/README.md @@ -11,7 +11,10 @@ The Seed Ruby library provides convenient access to the Seed APIs from Ruby. - [Environments](#environments) - [Errors](#errors) - [Advanced](#advanced) + - [Retries](#retries) - [Timeouts](#timeouts) + - [Additional Headers](#additional-headers) + - [Additional Query Parameters](#additional-query-parameters) - [Contributing](#contributing) ## Reference @@ -77,6 +80,28 @@ end ## Advanced +### Retries + +The SDK is instrumented with automatic retries. A request will be retried as long as the request is deemed +retryable and the number of retry attempts has not grown larger than the configured retry limit (default: 2). + +A request is deemed retryable when any of the following HTTP status codes is returned: + +- [408](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) (Timeout) +- [429](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) (Too Many Requests) +- [5XX](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500) (Internal Server Errors) + +Use the `max_retries` option to configure this behavior. + +```ruby +require "seed" + +client = Seed::Client.new( + base_url: "https://example.com", + max_retries: 3 # Configure max retries (default is 2) +) +``` + ### Timeouts The SDK defaults to a 60 second timeout. Use the `timeout` option to configure this behavior. @@ -90,6 +115,40 @@ response = client.auth.get_token_with_client_credentials( ) ``` +### Additional Headers + +If you would like to send additional headers as part of the request, use the `additional_headers` request option. + +```ruby +require "seed" + +response = client.auth.get_token_with_client_credentials( + ..., + request_options: { + additional_headers: { + "X-Custom-Header" => "custom-value" + } + } +) +``` + +### Additional Query Parameters + +If you would like to send additional query parameters as part of the request, use the `additional_query_parameters` request option. + +```ruby +require "seed" + +response = client.auth.get_token_with_client_credentials( + ..., + request_options: { + additional_query_parameters: { + "custom_param" => "custom-value" + } + } +) +``` + ## Contributing While we value open-source contributions to this SDK, this library is generated programmatically. diff --git a/seed/ruby-sdk-v2/inferred-auth-implicit-reference/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/inferred-auth-implicit-reference/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/inferred-auth-implicit-reference/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/inferred-auth-implicit-reference/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/inferred-auth-implicit-reference/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/inferred-auth-implicit-reference/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/inferred-auth-implicit-reference/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/inferred-auth-implicit-reference/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/inferred-auth-implicit/README.md b/seed/ruby-sdk-v2/inferred-auth-implicit/README.md index ec0ba71d7d76..b62feca344b9 100644 --- a/seed/ruby-sdk-v2/inferred-auth-implicit/README.md +++ b/seed/ruby-sdk-v2/inferred-auth-implicit/README.md @@ -11,7 +11,10 @@ The Seed Ruby library provides convenient access to the Seed APIs from Ruby. - [Environments](#environments) - [Errors](#errors) - [Advanced](#advanced) + - [Retries](#retries) - [Timeouts](#timeouts) + - [Additional Headers](#additional-headers) + - [Additional Query Parameters](#additional-query-parameters) - [Contributing](#contributing) ## Reference @@ -78,6 +81,28 @@ end ## Advanced +### Retries + +The SDK is instrumented with automatic retries. A request will be retried as long as the request is deemed +retryable and the number of retry attempts has not grown larger than the configured retry limit (default: 2). + +A request is deemed retryable when any of the following HTTP status codes is returned: + +- [408](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) (Timeout) +- [429](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) (Too Many Requests) +- [5XX](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500) (Internal Server Errors) + +Use the `max_retries` option to configure this behavior. + +```ruby +require "seed" + +client = Seed::Client.new( + base_url: "https://example.com", + max_retries: 3 # Configure max retries (default is 2) +) +``` + ### Timeouts The SDK defaults to a 60 second timeout. Use the `timeout` option to configure this behavior. @@ -91,6 +116,40 @@ response = client.auth.get_token_with_client_credentials( ) ``` +### Additional Headers + +If you would like to send additional headers as part of the request, use the `additional_headers` request option. + +```ruby +require "seed" + +response = client.auth.get_token_with_client_credentials( + ..., + request_options: { + additional_headers: { + "X-Custom-Header" => "custom-value" + } + } +) +``` + +### Additional Query Parameters + +If you would like to send additional query parameters as part of the request, use the `additional_query_parameters` request option. + +```ruby +require "seed" + +response = client.auth.get_token_with_client_credentials( + ..., + request_options: { + additional_query_parameters: { + "custom_param" => "custom-value" + } + } +) +``` + ## Contributing While we value open-source contributions to this SDK, this library is generated programmatically. diff --git a/seed/ruby-sdk-v2/inferred-auth-implicit/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/inferred-auth-implicit/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/inferred-auth-implicit/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/inferred-auth-implicit/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/inferred-auth-implicit/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/inferred-auth-implicit/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/inferred-auth-implicit/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/inferred-auth-implicit/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/license/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/license/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/license/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/license/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/license/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/license/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/license/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/license/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/literal/README.md b/seed/ruby-sdk-v2/literal/README.md index eeabc97e8ccc..935dd0994f72 100644 --- a/seed/ruby-sdk-v2/literal/README.md +++ b/seed/ruby-sdk-v2/literal/README.md @@ -11,7 +11,10 @@ The Seed Ruby library provides convenient access to the Seed APIs from Ruby. - [Environments](#environments) - [Errors](#errors) - [Advanced](#advanced) + - [Retries](#retries) - [Timeouts](#timeouts) + - [Additional Headers](#additional-headers) + - [Additional Query Parameters](#additional-query-parameters) - [Contributing](#contributing) ## Reference @@ -75,6 +78,28 @@ end ## Advanced +### Retries + +The SDK is instrumented with automatic retries. A request will be retried as long as the request is deemed +retryable and the number of retry attempts has not grown larger than the configured retry limit (default: 2). + +A request is deemed retryable when any of the following HTTP status codes is returned: + +- [408](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) (Timeout) +- [429](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) (Too Many Requests) +- [5XX](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500) (Internal Server Errors) + +Use the `max_retries` option to configure this behavior. + +```ruby +require "seed" + +client = Seed::Client.new( + base_url: "https://example.com", + max_retries: 3 # Configure max retries (default is 2) +) +``` + ### Timeouts The SDK defaults to a 60 second timeout. Use the `timeout` option to configure this behavior. @@ -88,6 +113,40 @@ response = client.headers.send_( ) ``` +### Additional Headers + +If you would like to send additional headers as part of the request, use the `additional_headers` request option. + +```ruby +require "seed" + +response = client.headers.send_( + ..., + request_options: { + additional_headers: { + "X-Custom-Header" => "custom-value" + } + } +) +``` + +### Additional Query Parameters + +If you would like to send additional query parameters as part of the request, use the `additional_query_parameters` request option. + +```ruby +require "seed" + +response = client.headers.send_( + ..., + request_options: { + additional_query_parameters: { + "custom_param" => "custom-value" + } + } +) +``` + ## Contributing While we value open-source contributions to this SDK, this library is generated programmatically. diff --git a/seed/ruby-sdk-v2/literal/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/literal/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/literal/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/literal/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/literal/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/literal/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/literal/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/literal/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/literals-unions/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/literals-unions/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/literals-unions/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/literals-unions/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/literals-unions/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/literals-unions/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/literals-unions/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/literals-unions/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/mixed-case/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/mixed-case/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/mixed-case/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/mixed-case/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/mixed-case/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/mixed-case/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/mixed-case/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/mixed-case/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/mixed-file-directory/README.md b/seed/ruby-sdk-v2/mixed-file-directory/README.md index 8ab91e9be05e..a1bb0ff4990b 100644 --- a/seed/ruby-sdk-v2/mixed-file-directory/README.md +++ b/seed/ruby-sdk-v2/mixed-file-directory/README.md @@ -11,7 +11,10 @@ The Seed Ruby library provides convenient access to the Seed APIs from Ruby. - [Environments](#environments) - [Errors](#errors) - [Advanced](#advanced) + - [Retries](#retries) - [Timeouts](#timeouts) + - [Additional Headers](#additional-headers) + - [Additional Query Parameters](#additional-query-parameters) - [Contributing](#contributing) ## Reference @@ -71,6 +74,28 @@ end ## Advanced +### Retries + +The SDK is instrumented with automatic retries. A request will be retried as long as the request is deemed +retryable and the number of retry attempts has not grown larger than the configured retry limit (default: 2). + +A request is deemed retryable when any of the following HTTP status codes is returned: + +- [408](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) (Timeout) +- [429](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) (Too Many Requests) +- [5XX](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500) (Internal Server Errors) + +Use the `max_retries` option to configure this behavior. + +```ruby +require "seed" + +client = Seed::Client.new( + base_url: "https://example.com", + max_retries: 3 # Configure max retries (default is 2) +) +``` + ### Timeouts The SDK defaults to a 60 second timeout. Use the `timeout` option to configure this behavior. @@ -84,6 +109,40 @@ response = client.organization.create( ) ``` +### Additional Headers + +If you would like to send additional headers as part of the request, use the `additional_headers` request option. + +```ruby +require "seed" + +response = client.organization.create( + ..., + request_options: { + additional_headers: { + "X-Custom-Header" => "custom-value" + } + } +) +``` + +### Additional Query Parameters + +If you would like to send additional query parameters as part of the request, use the `additional_query_parameters` request option. + +```ruby +require "seed" + +response = client.organization.create( + ..., + request_options: { + additional_query_parameters: { + "custom_param" => "custom-value" + } + } +) +``` + ## Contributing While we value open-source contributions to this SDK, this library is generated programmatically. diff --git a/seed/ruby-sdk-v2/mixed-file-directory/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/mixed-file-directory/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/mixed-file-directory/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/mixed-file-directory/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/mixed-file-directory/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/mixed-file-directory/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/mixed-file-directory/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/mixed-file-directory/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/multi-line-docs/README.md b/seed/ruby-sdk-v2/multi-line-docs/README.md index 20051cf6c6f7..138222cf7ccc 100644 --- a/seed/ruby-sdk-v2/multi-line-docs/README.md +++ b/seed/ruby-sdk-v2/multi-line-docs/README.md @@ -11,7 +11,10 @@ The Seed Ruby library provides convenient access to the Seed APIs from Ruby. - [Environments](#environments) - [Errors](#errors) - [Advanced](#advanced) + - [Retries](#retries) - [Timeouts](#timeouts) + - [Additional Headers](#additional-headers) + - [Additional Query Parameters](#additional-query-parameters) - [Contributing](#contributing) ## Reference @@ -74,6 +77,28 @@ end ## Advanced +### Retries + +The SDK is instrumented with automatic retries. A request will be retried as long as the request is deemed +retryable and the number of retry attempts has not grown larger than the configured retry limit (default: 2). + +A request is deemed retryable when any of the following HTTP status codes is returned: + +- [408](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) (Timeout) +- [429](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) (Too Many Requests) +- [5XX](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500) (Internal Server Errors) + +Use the `max_retries` option to configure this behavior. + +```ruby +require "seed" + +client = Seed::Client.new( + base_url: "https://example.com", + max_retries: 3 # Configure max retries (default is 2) +) +``` + ### Timeouts The SDK defaults to a 60 second timeout. Use the `timeout` option to configure this behavior. @@ -87,6 +112,40 @@ response = client.user.create_user( ) ``` +### Additional Headers + +If you would like to send additional headers as part of the request, use the `additional_headers` request option. + +```ruby +require "seed" + +response = client.user.create_user( + ..., + request_options: { + additional_headers: { + "X-Custom-Header" => "custom-value" + } + } +) +``` + +### Additional Query Parameters + +If you would like to send additional query parameters as part of the request, use the `additional_query_parameters` request option. + +```ruby +require "seed" + +response = client.user.create_user( + ..., + request_options: { + additional_query_parameters: { + "custom_param" => "custom-value" + } + } +) +``` + ## Contributing While we value open-source contributions to this SDK, this library is generated programmatically. diff --git a/seed/ruby-sdk-v2/multi-line-docs/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/multi-line-docs/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/multi-line-docs/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/multi-line-docs/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/multi-line-docs/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/multi-line-docs/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/multi-line-docs/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/multi-line-docs/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/multi-url-environment-no-default/README.md b/seed/ruby-sdk-v2/multi-url-environment-no-default/README.md index 09028e10c88f..6a16dee04791 100644 --- a/seed/ruby-sdk-v2/multi-url-environment-no-default/README.md +++ b/seed/ruby-sdk-v2/multi-url-environment-no-default/README.md @@ -11,7 +11,10 @@ The Seed Ruby library provides convenient access to the Seed APIs from Ruby. - [Environments](#environments) - [Errors](#errors) - [Advanced](#advanced) + - [Retries](#retries) - [Timeouts](#timeouts) + - [Additional Headers](#additional-headers) + - [Additional Query Parameters](#additional-query-parameters) - [Contributing](#contributing) ## Reference @@ -79,6 +82,28 @@ end ## Advanced +### Retries + +The SDK is instrumented with automatic retries. A request will be retried as long as the request is deemed +retryable and the number of retry attempts has not grown larger than the configured retry limit (default: 2). + +A request is deemed retryable when any of the following HTTP status codes is returned: + +- [408](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) (Timeout) +- [429](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) (Too Many Requests) +- [5XX](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500) (Internal Server Errors) + +Use the `max_retries` option to configure this behavior. + +```ruby +require "seed" + +client = Seed::Client.new( + base_url: "https://example.com", + max_retries: 3 # Configure max retries (default is 2) +) +``` + ### Timeouts The SDK defaults to a 60 second timeout. Use the `timeout` option to configure this behavior. @@ -92,6 +117,40 @@ response = client.ec_2.boot_instance( ) ``` +### Additional Headers + +If you would like to send additional headers as part of the request, use the `additional_headers` request option. + +```ruby +require "seed" + +response = client.ec_2.boot_instance( + ..., + request_options: { + additional_headers: { + "X-Custom-Header" => "custom-value" + } + } +) +``` + +### Additional Query Parameters + +If you would like to send additional query parameters as part of the request, use the `additional_query_parameters` request option. + +```ruby +require "seed" + +response = client.ec_2.boot_instance( + ..., + request_options: { + additional_query_parameters: { + "custom_param" => "custom-value" + } + } +) +``` + ## Contributing While we value open-source contributions to this SDK, this library is generated programmatically. diff --git a/seed/ruby-sdk-v2/multi-url-environment-no-default/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/multi-url-environment-no-default/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/multi-url-environment-no-default/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/multi-url-environment-no-default/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/multi-url-environment-no-default/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/multi-url-environment-no-default/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/multi-url-environment-no-default/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/multi-url-environment-no-default/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/multi-url-environment/README.md b/seed/ruby-sdk-v2/multi-url-environment/README.md index 09028e10c88f..6a16dee04791 100644 --- a/seed/ruby-sdk-v2/multi-url-environment/README.md +++ b/seed/ruby-sdk-v2/multi-url-environment/README.md @@ -11,7 +11,10 @@ The Seed Ruby library provides convenient access to the Seed APIs from Ruby. - [Environments](#environments) - [Errors](#errors) - [Advanced](#advanced) + - [Retries](#retries) - [Timeouts](#timeouts) + - [Additional Headers](#additional-headers) + - [Additional Query Parameters](#additional-query-parameters) - [Contributing](#contributing) ## Reference @@ -79,6 +82,28 @@ end ## Advanced +### Retries + +The SDK is instrumented with automatic retries. A request will be retried as long as the request is deemed +retryable and the number of retry attempts has not grown larger than the configured retry limit (default: 2). + +A request is deemed retryable when any of the following HTTP status codes is returned: + +- [408](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) (Timeout) +- [429](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) (Too Many Requests) +- [5XX](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500) (Internal Server Errors) + +Use the `max_retries` option to configure this behavior. + +```ruby +require "seed" + +client = Seed::Client.new( + base_url: "https://example.com", + max_retries: 3 # Configure max retries (default is 2) +) +``` + ### Timeouts The SDK defaults to a 60 second timeout. Use the `timeout` option to configure this behavior. @@ -92,6 +117,40 @@ response = client.ec_2.boot_instance( ) ``` +### Additional Headers + +If you would like to send additional headers as part of the request, use the `additional_headers` request option. + +```ruby +require "seed" + +response = client.ec_2.boot_instance( + ..., + request_options: { + additional_headers: { + "X-Custom-Header" => "custom-value" + } + } +) +``` + +### Additional Query Parameters + +If you would like to send additional query parameters as part of the request, use the `additional_query_parameters` request option. + +```ruby +require "seed" + +response = client.ec_2.boot_instance( + ..., + request_options: { + additional_query_parameters: { + "custom_param" => "custom-value" + } + } +) +``` + ## Contributing While we value open-source contributions to this SDK, this library is generated programmatically. diff --git a/seed/ruby-sdk-v2/multi-url-environment/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/multi-url-environment/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/multi-url-environment/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/multi-url-environment/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/multi-url-environment/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/multi-url-environment/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/multi-url-environment/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/multi-url-environment/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/multiple-request-bodies/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/multiple-request-bodies/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/multiple-request-bodies/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/multiple-request-bodies/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/multiple-request-bodies/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/multiple-request-bodies/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/multiple-request-bodies/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/multiple-request-bodies/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/no-environment/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/no-environment/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/no-environment/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/no-environment/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/no-environment/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/no-environment/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/no-environment/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/no-environment/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/no-retries/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/no-retries/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/no-retries/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/no-retries/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/no-retries/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/no-retries/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/no-retries/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/no-retries/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/nullable-allof-extends/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/nullable-allof-extends/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/nullable-allof-extends/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/nullable-allof-extends/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/nullable-allof-extends/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/nullable-allof-extends/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/nullable-allof-extends/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/nullable-allof-extends/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/nullable-optional/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/nullable-optional/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/nullable-optional/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/nullable-optional/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/nullable-optional/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/nullable-optional/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/nullable-optional/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/nullable-optional/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/nullable-request-body/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/nullable-request-body/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/nullable-request-body/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/nullable-request-body/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/nullable-request-body/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/nullable-request-body/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/nullable-request-body/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/nullable-request-body/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/nullable/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/nullable/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/nullable/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/nullable/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/nullable/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/nullable/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/nullable/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/nullable/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/oauth-client-credentials-custom/README.md b/seed/ruby-sdk-v2/oauth-client-credentials-custom/README.md index 03d25f5dfafa..a64b30a1d5ce 100644 --- a/seed/ruby-sdk-v2/oauth-client-credentials-custom/README.md +++ b/seed/ruby-sdk-v2/oauth-client-credentials-custom/README.md @@ -11,7 +11,10 @@ The Seed Ruby library provides convenient access to the Seed APIs from Ruby. - [Environments](#environments) - [Errors](#errors) - [Advanced](#advanced) + - [Retries](#retries) - [Timeouts](#timeouts) + - [Additional Headers](#additional-headers) + - [Additional Query Parameters](#additional-query-parameters) - [Contributing](#contributing) ## Reference @@ -82,6 +85,28 @@ end ## Advanced +### Retries + +The SDK is instrumented with automatic retries. A request will be retried as long as the request is deemed +retryable and the number of retry attempts has not grown larger than the configured retry limit (default: 2). + +A request is deemed retryable when any of the following HTTP status codes is returned: + +- [408](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) (Timeout) +- [429](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) (Too Many Requests) +- [5XX](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500) (Internal Server Errors) + +Use the `max_retries` option to configure this behavior. + +```ruby +require "seed" + +client = Seed::Client.new( + base_url: "https://example.com", + max_retries: 3 # Configure max retries (default is 2) +) +``` + ### Timeouts The SDK defaults to a 60 second timeout. Use the `timeout` option to configure this behavior. @@ -95,6 +120,40 @@ response = client.auth.get_token_with_client_credentials( ) ``` +### Additional Headers + +If you would like to send additional headers as part of the request, use the `additional_headers` request option. + +```ruby +require "seed" + +response = client.auth.get_token_with_client_credentials( + ..., + request_options: { + additional_headers: { + "X-Custom-Header" => "custom-value" + } + } +) +``` + +### Additional Query Parameters + +If you would like to send additional query parameters as part of the request, use the `additional_query_parameters` request option. + +```ruby +require "seed" + +response = client.auth.get_token_with_client_credentials( + ..., + request_options: { + additional_query_parameters: { + "custom_param" => "custom-value" + } + } +) +``` + ## Contributing While we value open-source contributions to this SDK, this library is generated programmatically. diff --git a/seed/ruby-sdk-v2/oauth-client-credentials-custom/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/oauth-client-credentials-custom/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/oauth-client-credentials-custom/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/oauth-client-credentials-custom/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/oauth-client-credentials-custom/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/oauth-client-credentials-custom/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/oauth-client-credentials-custom/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/oauth-client-credentials-custom/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/oauth-client-credentials-default/README.md b/seed/ruby-sdk-v2/oauth-client-credentials-default/README.md index 839d1aed390f..07a14f9f8472 100644 --- a/seed/ruby-sdk-v2/oauth-client-credentials-default/README.md +++ b/seed/ruby-sdk-v2/oauth-client-credentials-default/README.md @@ -11,7 +11,10 @@ The Seed Ruby library provides convenient access to the Seed APIs from Ruby. - [Environments](#environments) - [Errors](#errors) - [Advanced](#advanced) + - [Retries](#retries) - [Timeouts](#timeouts) + - [Additional Headers](#additional-headers) + - [Additional Query Parameters](#additional-query-parameters) - [Contributing](#contributing) ## Reference @@ -78,6 +81,28 @@ end ## Advanced +### Retries + +The SDK is instrumented with automatic retries. A request will be retried as long as the request is deemed +retryable and the number of retry attempts has not grown larger than the configured retry limit (default: 2). + +A request is deemed retryable when any of the following HTTP status codes is returned: + +- [408](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) (Timeout) +- [429](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) (Too Many Requests) +- [5XX](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500) (Internal Server Errors) + +Use the `max_retries` option to configure this behavior. + +```ruby +require "seed" + +client = Seed::Client.new( + base_url: "https://example.com", + max_retries: 3 # Configure max retries (default is 2) +) +``` + ### Timeouts The SDK defaults to a 60 second timeout. Use the `timeout` option to configure this behavior. @@ -91,6 +116,40 @@ response = client.auth.get_token( ) ``` +### Additional Headers + +If you would like to send additional headers as part of the request, use the `additional_headers` request option. + +```ruby +require "seed" + +response = client.auth.get_token( + ..., + request_options: { + additional_headers: { + "X-Custom-Header" => "custom-value" + } + } +) +``` + +### Additional Query Parameters + +If you would like to send additional query parameters as part of the request, use the `additional_query_parameters` request option. + +```ruby +require "seed" + +response = client.auth.get_token( + ..., + request_options: { + additional_query_parameters: { + "custom_param" => "custom-value" + } + } +) +``` + ## Contributing While we value open-source contributions to this SDK, this library is generated programmatically. diff --git a/seed/ruby-sdk-v2/oauth-client-credentials-default/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/oauth-client-credentials-default/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/oauth-client-credentials-default/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/oauth-client-credentials-default/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/oauth-client-credentials-default/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/oauth-client-credentials-default/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/oauth-client-credentials-default/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/oauth-client-credentials-default/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/oauth-client-credentials-environment-variables/README.md b/seed/ruby-sdk-v2/oauth-client-credentials-environment-variables/README.md index 605a0c53771c..3c3eb313e5cc 100644 --- a/seed/ruby-sdk-v2/oauth-client-credentials-environment-variables/README.md +++ b/seed/ruby-sdk-v2/oauth-client-credentials-environment-variables/README.md @@ -11,7 +11,10 @@ The Seed Ruby library provides convenient access to the Seed APIs from Ruby. - [Environments](#environments) - [Errors](#errors) - [Advanced](#advanced) + - [Retries](#retries) - [Timeouts](#timeouts) + - [Additional Headers](#additional-headers) + - [Additional Query Parameters](#additional-query-parameters) - [Contributing](#contributing) ## Reference @@ -80,6 +83,28 @@ end ## Advanced +### Retries + +The SDK is instrumented with automatic retries. A request will be retried as long as the request is deemed +retryable and the number of retry attempts has not grown larger than the configured retry limit (default: 2). + +A request is deemed retryable when any of the following HTTP status codes is returned: + +- [408](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) (Timeout) +- [429](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) (Too Many Requests) +- [5XX](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500) (Internal Server Errors) + +Use the `max_retries` option to configure this behavior. + +```ruby +require "seed" + +client = Seed::Client.new( + base_url: "https://example.com", + max_retries: 3 # Configure max retries (default is 2) +) +``` + ### Timeouts The SDK defaults to a 60 second timeout. Use the `timeout` option to configure this behavior. @@ -93,6 +118,40 @@ response = client.auth.get_token_with_client_credentials( ) ``` +### Additional Headers + +If you would like to send additional headers as part of the request, use the `additional_headers` request option. + +```ruby +require "seed" + +response = client.auth.get_token_with_client_credentials( + ..., + request_options: { + additional_headers: { + "X-Custom-Header" => "custom-value" + } + } +) +``` + +### Additional Query Parameters + +If you would like to send additional query parameters as part of the request, use the `additional_query_parameters` request option. + +```ruby +require "seed" + +response = client.auth.get_token_with_client_credentials( + ..., + request_options: { + additional_query_parameters: { + "custom_param" => "custom-value" + } + } +) +``` + ## Contributing While we value open-source contributions to this SDK, this library is generated programmatically. diff --git a/seed/ruby-sdk-v2/oauth-client-credentials-environment-variables/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/oauth-client-credentials-environment-variables/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/oauth-client-credentials-environment-variables/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/oauth-client-credentials-environment-variables/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/oauth-client-credentials-environment-variables/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/oauth-client-credentials-environment-variables/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/oauth-client-credentials-environment-variables/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/oauth-client-credentials-environment-variables/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/oauth-client-credentials-mandatory-auth/README.md b/seed/ruby-sdk-v2/oauth-client-credentials-mandatory-auth/README.md index b6749d1fa05f..8c203f783824 100644 --- a/seed/ruby-sdk-v2/oauth-client-credentials-mandatory-auth/README.md +++ b/seed/ruby-sdk-v2/oauth-client-credentials-mandatory-auth/README.md @@ -11,7 +11,10 @@ The Seed Ruby library provides convenient access to the Seed APIs from Ruby. - [Environments](#environments) - [Errors](#errors) - [Advanced](#advanced) + - [Retries](#retries) - [Timeouts](#timeouts) + - [Additional Headers](#additional-headers) + - [Additional Query Parameters](#additional-query-parameters) - [Contributing](#contributing) ## Reference @@ -80,6 +83,28 @@ end ## Advanced +### Retries + +The SDK is instrumented with automatic retries. A request will be retried as long as the request is deemed +retryable and the number of retry attempts has not grown larger than the configured retry limit (default: 2). + +A request is deemed retryable when any of the following HTTP status codes is returned: + +- [408](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) (Timeout) +- [429](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) (Too Many Requests) +- [5XX](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500) (Internal Server Errors) + +Use the `max_retries` option to configure this behavior. + +```ruby +require "seed" + +client = Seed::Client.new( + base_url: "https://example.com", + max_retries: 3 # Configure max retries (default is 2) +) +``` + ### Timeouts The SDK defaults to a 60 second timeout. Use the `timeout` option to configure this behavior. @@ -93,6 +118,40 @@ response = client.auth.get_token_with_client_credentials( ) ``` +### Additional Headers + +If you would like to send additional headers as part of the request, use the `additional_headers` request option. + +```ruby +require "seed" + +response = client.auth.get_token_with_client_credentials( + ..., + request_options: { + additional_headers: { + "X-Custom-Header" => "custom-value" + } + } +) +``` + +### Additional Query Parameters + +If you would like to send additional query parameters as part of the request, use the `additional_query_parameters` request option. + +```ruby +require "seed" + +response = client.auth.get_token_with_client_credentials( + ..., + request_options: { + additional_query_parameters: { + "custom_param" => "custom-value" + } + } +) +``` + ## Contributing While we value open-source contributions to this SDK, this library is generated programmatically. diff --git a/seed/ruby-sdk-v2/oauth-client-credentials-mandatory-auth/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/oauth-client-credentials-mandatory-auth/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/oauth-client-credentials-mandatory-auth/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/oauth-client-credentials-mandatory-auth/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/oauth-client-credentials-mandatory-auth/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/oauth-client-credentials-mandatory-auth/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/oauth-client-credentials-mandatory-auth/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/oauth-client-credentials-mandatory-auth/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/oauth-client-credentials-nested-root/README.md b/seed/ruby-sdk-v2/oauth-client-credentials-nested-root/README.md index 3e266f4165bd..d40103c0076a 100644 --- a/seed/ruby-sdk-v2/oauth-client-credentials-nested-root/README.md +++ b/seed/ruby-sdk-v2/oauth-client-credentials-nested-root/README.md @@ -11,7 +11,10 @@ The Seed Ruby library provides convenient access to the Seed APIs from Ruby. - [Environments](#environments) - [Errors](#errors) - [Advanced](#advanced) + - [Retries](#retries) - [Timeouts](#timeouts) + - [Additional Headers](#additional-headers) + - [Additional Query Parameters](#additional-query-parameters) - [Contributing](#contributing) ## Reference @@ -80,6 +83,28 @@ end ## Advanced +### Retries + +The SDK is instrumented with automatic retries. A request will be retried as long as the request is deemed +retryable and the number of retry attempts has not grown larger than the configured retry limit (default: 2). + +A request is deemed retryable when any of the following HTTP status codes is returned: + +- [408](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) (Timeout) +- [429](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) (Too Many Requests) +- [5XX](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500) (Internal Server Errors) + +Use the `max_retries` option to configure this behavior. + +```ruby +require "seed" + +client = Seed::Client.new( + base_url: "https://example.com", + max_retries: 3 # Configure max retries (default is 2) +) +``` + ### Timeouts The SDK defaults to a 60 second timeout. Use the `timeout` option to configure this behavior. @@ -93,6 +118,40 @@ response = client.auth.get_token( ) ``` +### Additional Headers + +If you would like to send additional headers as part of the request, use the `additional_headers` request option. + +```ruby +require "seed" + +response = client.auth.get_token( + ..., + request_options: { + additional_headers: { + "X-Custom-Header" => "custom-value" + } + } +) +``` + +### Additional Query Parameters + +If you would like to send additional query parameters as part of the request, use the `additional_query_parameters` request option. + +```ruby +require "seed" + +response = client.auth.get_token( + ..., + request_options: { + additional_query_parameters: { + "custom_param" => "custom-value" + } + } +) +``` + ## Contributing While we value open-source contributions to this SDK, this library is generated programmatically. diff --git a/seed/ruby-sdk-v2/oauth-client-credentials-nested-root/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/oauth-client-credentials-nested-root/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/oauth-client-credentials-nested-root/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/oauth-client-credentials-nested-root/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/oauth-client-credentials-nested-root/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/oauth-client-credentials-nested-root/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/oauth-client-credentials-nested-root/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/oauth-client-credentials-nested-root/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/oauth-client-credentials-reference/README.md b/seed/ruby-sdk-v2/oauth-client-credentials-reference/README.md index 1c689f442fd2..1e0ca085ea53 100644 --- a/seed/ruby-sdk-v2/oauth-client-credentials-reference/README.md +++ b/seed/ruby-sdk-v2/oauth-client-credentials-reference/README.md @@ -11,7 +11,10 @@ The Seed Ruby library provides convenient access to the Seed APIs from Ruby. - [Environments](#environments) - [Errors](#errors) - [Advanced](#advanced) + - [Retries](#retries) - [Timeouts](#timeouts) + - [Additional Headers](#additional-headers) + - [Additional Query Parameters](#additional-query-parameters) - [Contributing](#contributing) ## Reference @@ -77,6 +80,28 @@ end ## Advanced +### Retries + +The SDK is instrumented with automatic retries. A request will be retried as long as the request is deemed +retryable and the number of retry attempts has not grown larger than the configured retry limit (default: 2). + +A request is deemed retryable when any of the following HTTP status codes is returned: + +- [408](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) (Timeout) +- [429](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) (Too Many Requests) +- [5XX](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500) (Internal Server Errors) + +Use the `max_retries` option to configure this behavior. + +```ruby +require "seed" + +client = Seed::Client.new( + base_url: "https://example.com", + max_retries: 3 # Configure max retries (default is 2) +) +``` + ### Timeouts The SDK defaults to a 60 second timeout. Use the `timeout` option to configure this behavior. @@ -90,6 +115,40 @@ response = client.auth.get_token( ) ``` +### Additional Headers + +If you would like to send additional headers as part of the request, use the `additional_headers` request option. + +```ruby +require "seed" + +response = client.auth.get_token( + ..., + request_options: { + additional_headers: { + "X-Custom-Header" => "custom-value" + } + } +) +``` + +### Additional Query Parameters + +If you would like to send additional query parameters as part of the request, use the `additional_query_parameters` request option. + +```ruby +require "seed" + +response = client.auth.get_token( + ..., + request_options: { + additional_query_parameters: { + "custom_param" => "custom-value" + } + } +) +``` + ## Contributing While we value open-source contributions to this SDK, this library is generated programmatically. diff --git a/seed/ruby-sdk-v2/oauth-client-credentials-reference/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/oauth-client-credentials-reference/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/oauth-client-credentials-reference/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/oauth-client-credentials-reference/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/oauth-client-credentials-reference/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/oauth-client-credentials-reference/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/oauth-client-credentials-reference/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/oauth-client-credentials-reference/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/oauth-client-credentials-with-variables/README.md b/seed/ruby-sdk-v2/oauth-client-credentials-with-variables/README.md index 605a0c53771c..3c3eb313e5cc 100644 --- a/seed/ruby-sdk-v2/oauth-client-credentials-with-variables/README.md +++ b/seed/ruby-sdk-v2/oauth-client-credentials-with-variables/README.md @@ -11,7 +11,10 @@ The Seed Ruby library provides convenient access to the Seed APIs from Ruby. - [Environments](#environments) - [Errors](#errors) - [Advanced](#advanced) + - [Retries](#retries) - [Timeouts](#timeouts) + - [Additional Headers](#additional-headers) + - [Additional Query Parameters](#additional-query-parameters) - [Contributing](#contributing) ## Reference @@ -80,6 +83,28 @@ end ## Advanced +### Retries + +The SDK is instrumented with automatic retries. A request will be retried as long as the request is deemed +retryable and the number of retry attempts has not grown larger than the configured retry limit (default: 2). + +A request is deemed retryable when any of the following HTTP status codes is returned: + +- [408](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) (Timeout) +- [429](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) (Too Many Requests) +- [5XX](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500) (Internal Server Errors) + +Use the `max_retries` option to configure this behavior. + +```ruby +require "seed" + +client = Seed::Client.new( + base_url: "https://example.com", + max_retries: 3 # Configure max retries (default is 2) +) +``` + ### Timeouts The SDK defaults to a 60 second timeout. Use the `timeout` option to configure this behavior. @@ -93,6 +118,40 @@ response = client.auth.get_token_with_client_credentials( ) ``` +### Additional Headers + +If you would like to send additional headers as part of the request, use the `additional_headers` request option. + +```ruby +require "seed" + +response = client.auth.get_token_with_client_credentials( + ..., + request_options: { + additional_headers: { + "X-Custom-Header" => "custom-value" + } + } +) +``` + +### Additional Query Parameters + +If you would like to send additional query parameters as part of the request, use the `additional_query_parameters` request option. + +```ruby +require "seed" + +response = client.auth.get_token_with_client_credentials( + ..., + request_options: { + additional_query_parameters: { + "custom_param" => "custom-value" + } + } +) +``` + ## Contributing While we value open-source contributions to this SDK, this library is generated programmatically. diff --git a/seed/ruby-sdk-v2/oauth-client-credentials-with-variables/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/oauth-client-credentials-with-variables/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/oauth-client-credentials-with-variables/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/oauth-client-credentials-with-variables/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/oauth-client-credentials-with-variables/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/oauth-client-credentials-with-variables/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/oauth-client-credentials-with-variables/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/oauth-client-credentials-with-variables/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/oauth-client-credentials/README.md b/seed/ruby-sdk-v2/oauth-client-credentials/README.md index b6749d1fa05f..8c203f783824 100644 --- a/seed/ruby-sdk-v2/oauth-client-credentials/README.md +++ b/seed/ruby-sdk-v2/oauth-client-credentials/README.md @@ -11,7 +11,10 @@ The Seed Ruby library provides convenient access to the Seed APIs from Ruby. - [Environments](#environments) - [Errors](#errors) - [Advanced](#advanced) + - [Retries](#retries) - [Timeouts](#timeouts) + - [Additional Headers](#additional-headers) + - [Additional Query Parameters](#additional-query-parameters) - [Contributing](#contributing) ## Reference @@ -80,6 +83,28 @@ end ## Advanced +### Retries + +The SDK is instrumented with automatic retries. A request will be retried as long as the request is deemed +retryable and the number of retry attempts has not grown larger than the configured retry limit (default: 2). + +A request is deemed retryable when any of the following HTTP status codes is returned: + +- [408](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) (Timeout) +- [429](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) (Too Many Requests) +- [5XX](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500) (Internal Server Errors) + +Use the `max_retries` option to configure this behavior. + +```ruby +require "seed" + +client = Seed::Client.new( + base_url: "https://example.com", + max_retries: 3 # Configure max retries (default is 2) +) +``` + ### Timeouts The SDK defaults to a 60 second timeout. Use the `timeout` option to configure this behavior. @@ -93,6 +118,40 @@ response = client.auth.get_token_with_client_credentials( ) ``` +### Additional Headers + +If you would like to send additional headers as part of the request, use the `additional_headers` request option. + +```ruby +require "seed" + +response = client.auth.get_token_with_client_credentials( + ..., + request_options: { + additional_headers: { + "X-Custom-Header" => "custom-value" + } + } +) +``` + +### Additional Query Parameters + +If you would like to send additional query parameters as part of the request, use the `additional_query_parameters` request option. + +```ruby +require "seed" + +response = client.auth.get_token_with_client_credentials( + ..., + request_options: { + additional_query_parameters: { + "custom_param" => "custom-value" + } + } +) +``` + ## Contributing While we value open-source contributions to this SDK, this library is generated programmatically. diff --git a/seed/ruby-sdk-v2/oauth-client-credentials/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/oauth-client-credentials/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/oauth-client-credentials/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/oauth-client-credentials/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/oauth-client-credentials/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/oauth-client-credentials/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/oauth-client-credentials/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/oauth-client-credentials/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/object/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/object/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/object/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/object/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/object/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/object/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/object/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/object/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/objects-with-imports/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/objects-with-imports/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/objects-with-imports/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/objects-with-imports/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/objects-with-imports/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/objects-with-imports/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/objects-with-imports/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/objects-with-imports/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/optional/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/optional/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/optional/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/optional/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/optional/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/optional/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/optional/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/optional/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/package-yml/README.md b/seed/ruby-sdk-v2/package-yml/README.md index 10dd6e99cf5a..22df76536e95 100644 --- a/seed/ruby-sdk-v2/package-yml/README.md +++ b/seed/ruby-sdk-v2/package-yml/README.md @@ -11,7 +11,10 @@ The Seed Ruby library provides convenient access to the Seed APIs from Ruby. - [Environments](#environments) - [Errors](#errors) - [Advanced](#advanced) + - [Retries](#retries) - [Timeouts](#timeouts) + - [Additional Headers](#additional-headers) + - [Additional Query Parameters](#additional-query-parameters) - [Contributing](#contributing) ## Reference @@ -74,6 +77,28 @@ end ## Advanced +### Retries + +The SDK is instrumented with automatic retries. A request will be retried as long as the request is deemed +retryable and the number of retry attempts has not grown larger than the configured retry limit (default: 2). + +A request is deemed retryable when any of the following HTTP status codes is returned: + +- [408](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) (Timeout) +- [429](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) (Too Many Requests) +- [5XX](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500) (Internal Server Errors) + +Use the `max_retries` option to configure this behavior. + +```ruby +require "seed" + +client = Seed::Client.new( + base_url: "https://example.com", + max_retries: 3 # Configure max retries (default is 2) +) +``` + ### Timeouts The SDK defaults to a 60 second timeout. Use the `timeout` option to configure this behavior. @@ -87,6 +112,40 @@ response = client.echo( ) ``` +### Additional Headers + +If you would like to send additional headers as part of the request, use the `additional_headers` request option. + +```ruby +require "seed" + +response = client.echo( + ..., + request_options: { + additional_headers: { + "X-Custom-Header" => "custom-value" + } + } +) +``` + +### Additional Query Parameters + +If you would like to send additional query parameters as part of the request, use the `additional_query_parameters` request option. + +```ruby +require "seed" + +response = client.echo( + ..., + request_options: { + additional_query_parameters: { + "custom_param" => "custom-value" + } + } +) +``` + ## Contributing While we value open-source contributions to this SDK, this library is generated programmatically. diff --git a/seed/ruby-sdk-v2/package-yml/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/package-yml/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/package-yml/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/package-yml/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/package-yml/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/package-yml/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/package-yml/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/package-yml/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/pagination-custom/README.md b/seed/ruby-sdk-v2/pagination-custom/README.md index 87bc4941db5c..3a3e3a7bd8cc 100644 --- a/seed/ruby-sdk-v2/pagination-custom/README.md +++ b/seed/ruby-sdk-v2/pagination-custom/README.md @@ -12,7 +12,10 @@ The Seed Ruby library provides convenient access to the Seed APIs from Ruby. - [Pagination](#pagination) - [Errors](#errors) - [Advanced](#advanced) + - [Retries](#retries) - [Timeouts](#timeouts) + - [Additional Headers](#additional-headers) + - [Additional Query Parameters](#additional-query-parameters) - [Contributing](#contributing) ## Reference @@ -96,6 +99,28 @@ end ## Advanced +### Retries + +The SDK is instrumented with automatic retries. A request will be retried as long as the request is deemed +retryable and the number of retry attempts has not grown larger than the configured retry limit (default: 2). + +A request is deemed retryable when any of the following HTTP status codes is returned: + +- [408](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) (Timeout) +- [429](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) (Too Many Requests) +- [5XX](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500) (Internal Server Errors) + +Use the `max_retries` option to configure this behavior. + +```ruby +require "seed" + +client = Seed::Client.new( + base_url: "https://example.com", + max_retries: 3 # Configure max retries (default is 2) +) +``` + ### Timeouts The SDK defaults to a 60 second timeout. Use the `timeout` option to configure this behavior. @@ -109,6 +134,40 @@ response = client.users.list_usernames_custom( ) ``` +### Additional Headers + +If you would like to send additional headers as part of the request, use the `additional_headers` request option. + +```ruby +require "seed" + +response = client.users.list_usernames_custom( + ..., + request_options: { + additional_headers: { + "X-Custom-Header" => "custom-value" + } + } +) +``` + +### Additional Query Parameters + +If you would like to send additional query parameters as part of the request, use the `additional_query_parameters` request option. + +```ruby +require "seed" + +response = client.users.list_usernames_custom( + ..., + request_options: { + additional_query_parameters: { + "custom_param" => "custom-value" + } + } +) +``` + ## Contributing While we value open-source contributions to this SDK, this library is generated programmatically. diff --git a/seed/ruby-sdk-v2/pagination-custom/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/pagination-custom/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/pagination-custom/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/pagination-custom/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/pagination-custom/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/pagination-custom/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/pagination-custom/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/pagination-custom/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/pagination/README.md b/seed/ruby-sdk-v2/pagination/README.md index 6660190cd5d5..79b8fbd370b7 100644 --- a/seed/ruby-sdk-v2/pagination/README.md +++ b/seed/ruby-sdk-v2/pagination/README.md @@ -12,7 +12,10 @@ The Seed Ruby library provides convenient access to the Seed APIs from Ruby. - [Pagination](#pagination) - [Errors](#errors) - [Advanced](#advanced) + - [Retries](#retries) - [Timeouts](#timeouts) + - [Additional Headers](#additional-headers) + - [Additional Query Parameters](#additional-query-parameters) - [Contributing](#contributing) ## Reference @@ -109,6 +112,28 @@ end ## Advanced +### Retries + +The SDK is instrumented with automatic retries. A request will be retried as long as the request is deemed +retryable and the number of retry attempts has not grown larger than the configured retry limit (default: 2). + +A request is deemed retryable when any of the following HTTP status codes is returned: + +- [408](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) (Timeout) +- [429](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) (Too Many Requests) +- [5XX](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500) (Internal Server Errors) + +Use the `max_retries` option to configure this behavior. + +```ruby +require "seed" + +client = Seed::Client.new( + base_url: "https://example.com", + max_retries: 3 # Configure max retries (default is 2) +) +``` + ### Timeouts The SDK defaults to a 60 second timeout. Use the `timeout` option to configure this behavior. @@ -122,6 +147,40 @@ response = client.complex.search( ) ``` +### Additional Headers + +If you would like to send additional headers as part of the request, use the `additional_headers` request option. + +```ruby +require "seed" + +response = client.complex.search( + ..., + request_options: { + additional_headers: { + "X-Custom-Header" => "custom-value" + } + } +) +``` + +### Additional Query Parameters + +If you would like to send additional query parameters as part of the request, use the `additional_query_parameters` request option. + +```ruby +require "seed" + +response = client.complex.search( + ..., + request_options: { + additional_query_parameters: { + "custom_param" => "custom-value" + } + } +) +``` + ## Contributing While we value open-source contributions to this SDK, this library is generated programmatically. diff --git a/seed/ruby-sdk-v2/pagination/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/pagination/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/pagination/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/pagination/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/pagination/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/pagination/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/pagination/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/pagination/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/path-parameters/README.md b/seed/ruby-sdk-v2/path-parameters/README.md index 5254839b49bc..f76d515e0a41 100644 --- a/seed/ruby-sdk-v2/path-parameters/README.md +++ b/seed/ruby-sdk-v2/path-parameters/README.md @@ -11,7 +11,10 @@ The Seed Ruby library provides convenient access to the Seed APIs from Ruby. - [Environments](#environments) - [Errors](#errors) - [Advanced](#advanced) + - [Retries](#retries) - [Timeouts](#timeouts) + - [Additional Headers](#additional-headers) + - [Additional Query Parameters](#additional-query-parameters) - [Contributing](#contributing) ## Reference @@ -74,6 +77,28 @@ end ## Advanced +### Retries + +The SDK is instrumented with automatic retries. A request will be retried as long as the request is deemed +retryable and the number of retry attempts has not grown larger than the configured retry limit (default: 2). + +A request is deemed retryable when any of the following HTTP status codes is returned: + +- [408](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) (Timeout) +- [429](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) (Too Many Requests) +- [5XX](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500) (Internal Server Errors) + +Use the `max_retries` option to configure this behavior. + +```ruby +require "seed" + +client = Seed::Client.new( + base_url: "https://example.com", + max_retries: 3 # Configure max retries (default is 2) +) +``` + ### Timeouts The SDK defaults to a 60 second timeout. Use the `timeout` option to configure this behavior. @@ -87,6 +112,40 @@ response = client.user.create_user( ) ``` +### Additional Headers + +If you would like to send additional headers as part of the request, use the `additional_headers` request option. + +```ruby +require "seed" + +response = client.user.create_user( + ..., + request_options: { + additional_headers: { + "X-Custom-Header" => "custom-value" + } + } +) +``` + +### Additional Query Parameters + +If you would like to send additional query parameters as part of the request, use the `additional_query_parameters` request option. + +```ruby +require "seed" + +response = client.user.create_user( + ..., + request_options: { + additional_query_parameters: { + "custom_param" => "custom-value" + } + } +) +``` + ## Contributing While we value open-source contributions to this SDK, this library is generated programmatically. diff --git a/seed/ruby-sdk-v2/path-parameters/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/path-parameters/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/path-parameters/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/path-parameters/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/path-parameters/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/path-parameters/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/path-parameters/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/path-parameters/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/plain-text/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/plain-text/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/plain-text/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/plain-text/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/plain-text/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/plain-text/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/plain-text/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/plain-text/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/property-access/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/property-access/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/property-access/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/property-access/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/property-access/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/property-access/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/property-access/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/property-access/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/public-object/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/public-object/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/public-object/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/public-object/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/public-object/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/public-object/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/public-object/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/public-object/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/query-parameters-openapi-as-objects/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/query-parameters-openapi-as-objects/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/query-parameters-openapi-as-objects/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/query-parameters-openapi-as-objects/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/query-parameters-openapi-as-objects/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/query-parameters-openapi-as-objects/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/query-parameters-openapi-as-objects/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/query-parameters-openapi-as-objects/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/query-parameters-openapi/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/query-parameters-openapi/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/query-parameters-openapi/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/query-parameters-openapi/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/query-parameters-openapi/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/query-parameters-openapi/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/query-parameters-openapi/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/query-parameters-openapi/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/request-parameters/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/request-parameters/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/request-parameters/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/request-parameters/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/request-parameters/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/request-parameters/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/request-parameters/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/request-parameters/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/required-nullable/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/required-nullable/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/required-nullable/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/required-nullable/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/required-nullable/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/required-nullable/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/required-nullable/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/required-nullable/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/reserved-keywords/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/reserved-keywords/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/reserved-keywords/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/reserved-keywords/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/reserved-keywords/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/reserved-keywords/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/reserved-keywords/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/reserved-keywords/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/response-property/README.md b/seed/ruby-sdk-v2/response-property/README.md index c33524aa41f5..8bdd93d5f550 100644 --- a/seed/ruby-sdk-v2/response-property/README.md +++ b/seed/ruby-sdk-v2/response-property/README.md @@ -11,7 +11,10 @@ The Seed Ruby library provides convenient access to the Seed APIs from Ruby. - [Environments](#environments) - [Errors](#errors) - [Advanced](#advanced) + - [Retries](#retries) - [Timeouts](#timeouts) + - [Additional Headers](#additional-headers) + - [Additional Query Parameters](#additional-query-parameters) - [Contributing](#contributing) ## Reference @@ -71,6 +74,28 @@ end ## Advanced +### Retries + +The SDK is instrumented with automatic retries. A request will be retried as long as the request is deemed +retryable and the number of retry attempts has not grown larger than the configured retry limit (default: 2). + +A request is deemed retryable when any of the following HTTP status codes is returned: + +- [408](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) (Timeout) +- [429](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) (Too Many Requests) +- [5XX](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500) (Internal Server Errors) + +Use the `max_retries` option to configure this behavior. + +```ruby +require "seed" + +client = Seed::Client.new( + base_url: "https://example.com", + max_retries: 3 # Configure max retries (default is 2) +) +``` + ### Timeouts The SDK defaults to a 60 second timeout. Use the `timeout` option to configure this behavior. @@ -84,6 +109,40 @@ response = client.service.get_movie( ) ``` +### Additional Headers + +If you would like to send additional headers as part of the request, use the `additional_headers` request option. + +```ruby +require "seed" + +response = client.service.get_movie( + ..., + request_options: { + additional_headers: { + "X-Custom-Header" => "custom-value" + } + } +) +``` + +### Additional Query Parameters + +If you would like to send additional query parameters as part of the request, use the `additional_query_parameters` request option. + +```ruby +require "seed" + +response = client.service.get_movie( + ..., + request_options: { + additional_query_parameters: { + "custom_param" => "custom-value" + } + } +) +``` + ## Contributing While we value open-source contributions to this SDK, this library is generated programmatically. diff --git a/seed/ruby-sdk-v2/response-property/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/response-property/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/response-property/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/response-property/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/response-property/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/response-property/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/response-property/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/response-property/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/ruby-reserved-word-properties/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/ruby-reserved-word-properties/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/ruby-reserved-word-properties/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/ruby-reserved-word-properties/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/ruby-reserved-word-properties/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/ruby-reserved-word-properties/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/ruby-reserved-word-properties/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/ruby-reserved-word-properties/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/server-sent-event-examples/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/server-sent-event-examples/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/server-sent-event-examples/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/server-sent-event-examples/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/server-sent-event-examples/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/server-sent-event-examples/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/server-sent-event-examples/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/server-sent-event-examples/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/server-sent-events/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/server-sent-events/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/server-sent-events/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/server-sent-events/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/server-sent-events/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/server-sent-events/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/server-sent-events/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/server-sent-events/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/simple-api/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/simple-api/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/simple-api/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/simple-api/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/simple-api/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/simple-api/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/simple-api/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/simple-api/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/simple-fhir/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/simple-fhir/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/simple-fhir/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/simple-fhir/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/simple-fhir/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/simple-fhir/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/simple-fhir/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/simple-fhir/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/single-url-environment-default/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/single-url-environment-default/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/single-url-environment-default/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/single-url-environment-default/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/single-url-environment-default/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/single-url-environment-default/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/single-url-environment-default/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/single-url-environment-default/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/single-url-environment-no-default/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/single-url-environment-no-default/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/single-url-environment-no-default/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/single-url-environment-no-default/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/single-url-environment-no-default/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/single-url-environment-no-default/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/single-url-environment-no-default/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/single-url-environment-no-default/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/streaming-parameter/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/streaming-parameter/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/streaming-parameter/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/streaming-parameter/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/streaming-parameter/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/streaming-parameter/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/streaming-parameter/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/streaming-parameter/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/streaming/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/streaming/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/streaming/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/streaming/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/streaming/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/streaming/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/streaming/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/streaming/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/trace/README.md b/seed/ruby-sdk-v2/trace/README.md index 03d167f1f14b..9536ccc72e3b 100644 --- a/seed/ruby-sdk-v2/trace/README.md +++ b/seed/ruby-sdk-v2/trace/README.md @@ -11,7 +11,10 @@ The Seed Ruby library provides convenient access to the Seed APIs from Ruby. - [Environments](#environments) - [Errors](#errors) - [Advanced](#advanced) + - [Retries](#retries) - [Timeouts](#timeouts) + - [Additional Headers](#additional-headers) + - [Additional Query Parameters](#additional-query-parameters) - [Contributing](#contributing) ## Reference @@ -82,6 +85,28 @@ end ## Advanced +### Retries + +The SDK is instrumented with automatic retries. A request will be retried as long as the request is deemed +retryable and the number of retry attempts has not grown larger than the configured retry limit (default: 2). + +A request is deemed retryable when any of the following HTTP status codes is returned: + +- [408](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) (Timeout) +- [429](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) (Too Many Requests) +- [5XX](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500) (Internal Server Errors) + +Use the `max_retries` option to configure this behavior. + +```ruby +require "seed" + +client = Seed::Client.new( + base_url: "https://example.com", + max_retries: 3 # Configure max retries (default is 2) +) +``` + ### Timeouts The SDK defaults to a 60 second timeout. Use the `timeout` option to configure this behavior. @@ -95,6 +120,40 @@ response = client.admin.update_test_submission_status( ) ``` +### Additional Headers + +If you would like to send additional headers as part of the request, use the `additional_headers` request option. + +```ruby +require "seed" + +response = client.admin.update_test_submission_status( + ..., + request_options: { + additional_headers: { + "X-Custom-Header" => "custom-value" + } + } +) +``` + +### Additional Query Parameters + +If you would like to send additional query parameters as part of the request, use the `additional_query_parameters` request option. + +```ruby +require "seed" + +response = client.admin.update_test_submission_status( + ..., + request_options: { + additional_query_parameters: { + "custom_param" => "custom-value" + } + } +) +``` + ## Contributing While we value open-source contributions to this SDK, this library is generated programmatically. diff --git a/seed/ruby-sdk-v2/trace/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/trace/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/trace/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/trace/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/trace/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/trace/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/trace/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/trace/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/undiscriminated-union-with-response-property/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/undiscriminated-union-with-response-property/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/undiscriminated-union-with-response-property/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/undiscriminated-union-with-response-property/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/undiscriminated-union-with-response-property/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/undiscriminated-union-with-response-property/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/undiscriminated-union-with-response-property/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/undiscriminated-union-with-response-property/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/undiscriminated-unions/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/undiscriminated-unions/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/undiscriminated-unions/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/undiscriminated-unions/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/undiscriminated-unions/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/undiscriminated-unions/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/undiscriminated-unions/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/undiscriminated-unions/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/unions-with-local-date/README.md b/seed/ruby-sdk-v2/unions-with-local-date/README.md index 83f256647876..e62a3a851bef 100644 --- a/seed/ruby-sdk-v2/unions-with-local-date/README.md +++ b/seed/ruby-sdk-v2/unions-with-local-date/README.md @@ -11,7 +11,10 @@ The Seed Ruby library provides convenient access to the Seed APIs from Ruby. - [Environments](#environments) - [Errors](#errors) - [Advanced](#advanced) + - [Retries](#retries) - [Timeouts](#timeouts) + - [Additional Headers](#additional-headers) + - [Additional Query Parameters](#additional-query-parameters) - [Contributing](#contributing) ## Reference @@ -71,6 +74,28 @@ end ## Advanced +### Retries + +The SDK is instrumented with automatic retries. A request will be retried as long as the request is deemed +retryable and the number of retry attempts has not grown larger than the configured retry limit (default: 2). + +A request is deemed retryable when any of the following HTTP status codes is returned: + +- [408](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) (Timeout) +- [429](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) (Too Many Requests) +- [5XX](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500) (Internal Server Errors) + +Use the `max_retries` option to configure this behavior. + +```ruby +require "seed" + +client = Seed::Client.new( + base_url: "https://example.com", + max_retries: 3 # Configure max retries (default is 2) +) +``` + ### Timeouts The SDK defaults to a 60 second timeout. Use the `timeout` option to configure this behavior. @@ -84,6 +109,40 @@ response = client.bigunion.get( ) ``` +### Additional Headers + +If you would like to send additional headers as part of the request, use the `additional_headers` request option. + +```ruby +require "seed" + +response = client.bigunion.get( + ..., + request_options: { + additional_headers: { + "X-Custom-Header" => "custom-value" + } + } +) +``` + +### Additional Query Parameters + +If you would like to send additional query parameters as part of the request, use the `additional_query_parameters` request option. + +```ruby +require "seed" + +response = client.bigunion.get( + ..., + request_options: { + additional_query_parameters: { + "custom_param" => "custom-value" + } + } +) +``` + ## Contributing While we value open-source contributions to this SDK, this library is generated programmatically. diff --git a/seed/ruby-sdk-v2/unions-with-local-date/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/unions-with-local-date/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/unions-with-local-date/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/unions-with-local-date/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/unions-with-local-date/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/unions-with-local-date/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/unions-with-local-date/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/unions-with-local-date/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/unions/README.md b/seed/ruby-sdk-v2/unions/README.md index 83f256647876..e62a3a851bef 100644 --- a/seed/ruby-sdk-v2/unions/README.md +++ b/seed/ruby-sdk-v2/unions/README.md @@ -11,7 +11,10 @@ The Seed Ruby library provides convenient access to the Seed APIs from Ruby. - [Environments](#environments) - [Errors](#errors) - [Advanced](#advanced) + - [Retries](#retries) - [Timeouts](#timeouts) + - [Additional Headers](#additional-headers) + - [Additional Query Parameters](#additional-query-parameters) - [Contributing](#contributing) ## Reference @@ -71,6 +74,28 @@ end ## Advanced +### Retries + +The SDK is instrumented with automatic retries. A request will be retried as long as the request is deemed +retryable and the number of retry attempts has not grown larger than the configured retry limit (default: 2). + +A request is deemed retryable when any of the following HTTP status codes is returned: + +- [408](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) (Timeout) +- [429](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) (Too Many Requests) +- [5XX](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500) (Internal Server Errors) + +Use the `max_retries` option to configure this behavior. + +```ruby +require "seed" + +client = Seed::Client.new( + base_url: "https://example.com", + max_retries: 3 # Configure max retries (default is 2) +) +``` + ### Timeouts The SDK defaults to a 60 second timeout. Use the `timeout` option to configure this behavior. @@ -84,6 +109,40 @@ response = client.bigunion.get( ) ``` +### Additional Headers + +If you would like to send additional headers as part of the request, use the `additional_headers` request option. + +```ruby +require "seed" + +response = client.bigunion.get( + ..., + request_options: { + additional_headers: { + "X-Custom-Header" => "custom-value" + } + } +) +``` + +### Additional Query Parameters + +If you would like to send additional query parameters as part of the request, use the `additional_query_parameters` request option. + +```ruby +require "seed" + +response = client.bigunion.get( + ..., + request_options: { + additional_query_parameters: { + "custom_param" => "custom-value" + } + } +) +``` + ## Contributing While we value open-source contributions to this SDK, this library is generated programmatically. diff --git a/seed/ruby-sdk-v2/unions/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/unions/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/unions/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/unions/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/unions/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/unions/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/unions/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/unions/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/unknown/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/unknown/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/unknown/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/unknown/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/unknown/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/unknown/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/unknown/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/unknown/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/url-form-encoded/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/url-form-encoded/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/url-form-encoded/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/url-form-encoded/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/url-form-encoded/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/url-form-encoded/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/url-form-encoded/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/url-form-encoded/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/validation/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/validation/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/validation/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/validation/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/validation/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/validation/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/validation/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/validation/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/variables/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/variables/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/variables/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/variables/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/variables/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/variables/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/variables/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/variables/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/version-no-default/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/version-no-default/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/version-no-default/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/version-no-default/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/version-no-default/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/version-no-default/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/version-no-default/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/version-no-default/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/version/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/version/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/version/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/version/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/version/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/version/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/version/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/version/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/websocket-bearer-auth/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/websocket-bearer-auth/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/websocket-bearer-auth/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/websocket-bearer-auth/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/websocket-bearer-auth/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/websocket-bearer-auth/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/websocket-bearer-auth/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/websocket-bearer-auth/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/websocket-inferred-auth/README.md b/seed/ruby-sdk-v2/websocket-inferred-auth/README.md index ec0ba71d7d76..b62feca344b9 100644 --- a/seed/ruby-sdk-v2/websocket-inferred-auth/README.md +++ b/seed/ruby-sdk-v2/websocket-inferred-auth/README.md @@ -11,7 +11,10 @@ The Seed Ruby library provides convenient access to the Seed APIs from Ruby. - [Environments](#environments) - [Errors](#errors) - [Advanced](#advanced) + - [Retries](#retries) - [Timeouts](#timeouts) + - [Additional Headers](#additional-headers) + - [Additional Query Parameters](#additional-query-parameters) - [Contributing](#contributing) ## Reference @@ -78,6 +81,28 @@ end ## Advanced +### Retries + +The SDK is instrumented with automatic retries. A request will be retried as long as the request is deemed +retryable and the number of retry attempts has not grown larger than the configured retry limit (default: 2). + +A request is deemed retryable when any of the following HTTP status codes is returned: + +- [408](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) (Timeout) +- [429](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) (Too Many Requests) +- [5XX](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500) (Internal Server Errors) + +Use the `max_retries` option to configure this behavior. + +```ruby +require "seed" + +client = Seed::Client.new( + base_url: "https://example.com", + max_retries: 3 # Configure max retries (default is 2) +) +``` + ### Timeouts The SDK defaults to a 60 second timeout. Use the `timeout` option to configure this behavior. @@ -91,6 +116,40 @@ response = client.auth.get_token_with_client_credentials( ) ``` +### Additional Headers + +If you would like to send additional headers as part of the request, use the `additional_headers` request option. + +```ruby +require "seed" + +response = client.auth.get_token_with_client_credentials( + ..., + request_options: { + additional_headers: { + "X-Custom-Header" => "custom-value" + } + } +) +``` + +### Additional Query Parameters + +If you would like to send additional query parameters as part of the request, use the `additional_query_parameters` request option. + +```ruby +require "seed" + +response = client.auth.get_token_with_client_credentials( + ..., + request_options: { + additional_query_parameters: { + "custom_param" => "custom-value" + } + } +) +``` + ## Contributing While we value open-source contributions to this SDK, this library is generated programmatically. diff --git a/seed/ruby-sdk-v2/websocket-inferred-auth/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/websocket-inferred-auth/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/websocket-inferred-auth/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/websocket-inferred-auth/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/websocket-inferred-auth/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/websocket-inferred-auth/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/websocket-inferred-auth/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/websocket-inferred-auth/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk-v2/websocket/lib/seed/internal/http/base_request.rb b/seed/ruby-sdk-v2/websocket/lib/seed/internal/http/base_request.rb index 5f65f1327023..8f4728534866 100644 --- a/seed/ruby-sdk-v2/websocket/lib/seed/internal/http/base_request.rb +++ b/seed/ruby-sdk-v2/websocket/lib/seed/internal/http/base_request.rb @@ -22,6 +22,12 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option @request_options = request_options end + # @return [Hash] The query parameters merged with additional query parameters from request options. + def encode_query + additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {} + @query.merge(additional_query) + end + # Child classes should implement: # - encode_headers: Returns the encoded HTTP request headers. # - encode_body: Returns the encoded HTTP request body. diff --git a/seed/ruby-sdk-v2/websocket/lib/seed/internal/http/raw_client.rb b/seed/ruby-sdk-v2/websocket/lib/seed/internal/http/raw_client.rb index fff82a2d3036..d29e32a44094 100644 --- a/seed/ruby-sdk-v2/websocket/lib/seed/internal/http/raw_client.rb +++ b/seed/ruby-sdk-v2/websocket/lib/seed/internal/http/raw_client.rb @@ -47,17 +47,19 @@ def send(request) # @param request [Seed::Internal::Http::BaseRequest] The HTTP request. # @return [URI::Generic] The URL. def build_url(request) + encoded_query = request.encode_query + # If the path is already an absolute URL, use it directly if request.path.start_with?("http://", "https://") url = request.path - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? return URI.parse(url) end path = request.path.start_with?("/") ? request.path[1..] : request.path base = request.base_url || @base_url url = "#{base.chomp("/")}/#{path}" - url = "#{url}?#{encode_query(request.query)}" if request.query&.any? + url = "#{url}?#{encode_query(encoded_query)}" if encoded_query&.any? URI.parse(url) end diff --git a/seed/ruby-sdk/seed.yml b/seed/ruby-sdk/seed.yml index 9c1ec0ad619f..2111ab490fbc 100644 --- a/seed/ruby-sdk/seed.yml +++ b/seed/ruby-sdk/seed.yml @@ -162,6 +162,7 @@ allowedFailures: - variables - version - version-no-default + - webhook-audience - websocket - websocket-bearer-auth - websocket-inferred-auth diff --git a/seed/ts-sdk/nullable-optional/src/api/resources/nullableOptional/client/Client.ts b/seed/ts-sdk/nullable-optional/src/api/resources/nullableOptional/client/Client.ts index a1c1e8ac70bf..9873aaf384cf 100644 --- a/seed/ts-sdk/nullable-optional/src/api/resources/nullableOptional/client/Client.ts +++ b/seed/ts-sdk/nullable-optional/src/api/resources/nullableOptional/client/Client.ts @@ -759,9 +759,9 @@ export class NullableOptionalClient { ): Promise> { const { role, status, secondaryRole } = request; const _queryParams: Record = { - role: role !== undefined ? role : null, + role: role !== undefined ? role : undefined, status: status != null ? status : undefined, - secondaryRole: secondaryRole !== undefined ? secondaryRole : null, + secondaryRole: secondaryRole !== undefined ? secondaryRole : undefined, }; const _headers: core.Fetcher.Args["headers"] = mergeHeaders(this._options?.headers, requestOptions?.headers); const _response = await core.fetcher({ diff --git a/seed/ts-sdk/webhook-audience/.fern/metadata.json b/seed/ts-sdk/webhook-audience/.fern/metadata.json new file mode 100644 index 000000000000..5df575fb37ad --- /dev/null +++ b/seed/ts-sdk/webhook-audience/.fern/metadata.json @@ -0,0 +1,6 @@ +{ + "cliVersion": "DUMMY", + "generatorName": "fernapi/fern-typescript-sdk", + "generatorVersion": "latest", + "sdkVersion": "0.0.1" +} diff --git a/seed/ts-sdk/webhook-audience/.github/workflows/ci.yml b/seed/ts-sdk/webhook-audience/.github/workflows/ci.yml new file mode 100644 index 000000000000..a98d4d00ff0e --- /dev/null +++ b/seed/ts-sdk/webhook-audience/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: ci + +on: [push] + +jobs: + compile: + runs-on: ubuntu-latest + + steps: + - name: Checkout repo + uses: actions/checkout@v6 + + - name: Set up node + uses: actions/setup-node@v6 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Compile + run: pnpm build + + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout repo + uses: actions/checkout@v6 + + - name: Set up node + uses: actions/setup-node@v6 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Test + run: pnpm test diff --git a/seed/ts-sdk/webhook-audience/.gitignore b/seed/ts-sdk/webhook-audience/.gitignore new file mode 100644 index 000000000000..72271e049c02 --- /dev/null +++ b/seed/ts-sdk/webhook-audience/.gitignore @@ -0,0 +1,3 @@ +node_modules +.DS_Store +/dist \ No newline at end of file diff --git a/seed/ts-sdk/webhook-audience/CONTRIBUTING.md b/seed/ts-sdk/webhook-audience/CONTRIBUTING.md new file mode 100644 index 000000000000..fe5bc2f77e0b --- /dev/null +++ b/seed/ts-sdk/webhook-audience/CONTRIBUTING.md @@ -0,0 +1,133 @@ +# Contributing + +Thanks for your interest in contributing to this SDK! This document provides guidelines for contributing to the project. + +## Getting Started + +### Prerequisites + +- Node.js 20 or higher +- pnpm package manager + +### Installation + +Install the project dependencies: + +```bash +pnpm install +``` + +### Building + +Build the project: + +```bash +pnpm build +``` + +### Testing + +Run the test suite: + +```bash +pnpm test +``` + +Run specific test types: +- `pnpm test:unit` - Run unit tests +- `pnpm test:wire` - Run wire/integration tests + +### Linting and Formatting + +Check code style: + +```bash +pnpm run lint +pnpm run format:check +``` + +Fix code style issues: + +```bash +pnpm run lint:fix +pnpm run format:fix +``` + +Or use the combined check command: + +```bash +pnpm run check:fix +``` + +## About Generated Code + +**Important**: Most files in this SDK are automatically generated by [Fern](https://buildwithfern.com) from the API definition. Direct modifications to generated files will be overwritten the next time the SDK is generated. + +### Generated Files + +The following directories contain generated code: +- `src/api/` - API client classes and types +- `src/serialization/` - Serialization/deserialization logic +- Most TypeScript files in `src/` + +### How to Customize + +If you need to customize the SDK, you have two options: + +#### Option 1: Use `.fernignore` + +For custom code that should persist across SDK regenerations: + +1. Create a `.fernignore` file in the project root +2. Add file patterns for files you want to preserve (similar to `.gitignore` syntax) +3. Add your custom code to those files + +Files listed in `.fernignore` will not be overwritten when the SDK is regenerated. + +For more information, see the [Fern documentation on custom code](https://buildwithfern.com/learn/sdks/overview/custom-code). + +#### Option 2: Contribute to the Generator + +If you want to change how code is generated for all users of this SDK: + +1. The TypeScript SDK generator lives in the [Fern repository](https://github.com/fern-api/fern) +2. Generator code is located at `generators/typescript/sdk/` +3. Follow the [Fern contributing guidelines](https://github.com/fern-api/fern/blob/main/CONTRIBUTING.md) +4. Submit a pull request with your changes to the generator + +This approach is best for: +- Bug fixes in generated code +- New features that would benefit all users +- Improvements to code generation patterns + +## Making Changes + +### Workflow + +1. Create a new branch for your changes +2. Make your modifications +3. Run tests to ensure nothing breaks: `pnpm test` +4. Run linting and formatting: `pnpm run check:fix` +5. Build the project: `pnpm build` +6. Commit your changes with a clear commit message +7. Push your branch and create a pull request + +### Commit Messages + +Write clear, descriptive commit messages that explain what changed and why. + +### Code Style + +This project uses automated code formatting and linting. Run `pnpm run check:fix` before committing to ensure your code meets the project's style guidelines. + +## Questions or Issues? + +If you have questions or run into issues: + +1. Check the [Fern documentation](https://buildwithfern.com) +2. Search existing [GitHub issues](https://github.com/fern-api/fern/issues) +3. Open a new issue if your question hasn't been addressed + +## License + +By contributing to this project, you agree that your contributions will be licensed under the same license as the project. diff --git a/seed/ts-sdk/webhook-audience/biome.json b/seed/ts-sdk/webhook-audience/biome.json new file mode 100644 index 000000000000..371d3650883e --- /dev/null +++ b/seed/ts-sdk/webhook-audience/biome.json @@ -0,0 +1,74 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.11/schema.json", + "root": true, + "vcs": { + "enabled": false + }, + "files": { + "ignoreUnknown": true, + "includes": [ + "**", + "!!dist", + "!!**/dist", + "!!lib", + "!!**/lib", + "!!_tmp_*", + "!!**/_tmp_*", + "!!*.tmp", + "!!**/*.tmp", + "!!.tmp/", + "!!**/.tmp/", + "!!*.log", + "!!**/*.log", + "!!**/.DS_Store", + "!!**/Thumbs.db" + ] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 4, + "lineWidth": 120 + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + }, + "linter": { + "rules": { + "style": { + "useNodejsImportProtocol": "off" + }, + "suspicious": { + "noAssignInExpressions": "warn", + "noUselessEscapeInString": { + "level": "warn", + "fix": "none", + "options": {} + }, + "noThenProperty": "warn", + "useIterableCallbackReturn": "warn", + "noShadowRestrictedNames": "warn", + "noTsIgnore": { + "level": "warn", + "fix": "none", + "options": {} + }, + "noConfusingVoidType": { + "level": "warn", + "fix": "none", + "options": {} + } + } + } + } +} diff --git a/seed/ts-sdk/webhook-audience/package.json b/seed/ts-sdk/webhook-audience/package.json new file mode 100644 index 000000000000..cc91505cc3b8 --- /dev/null +++ b/seed/ts-sdk/webhook-audience/package.json @@ -0,0 +1,69 @@ +{ + "name": "@fern/webhook-audience", + "version": "0.0.1", + "private": false, + "repository": { + "type": "git", + "url": "git+https://github.com/webhook-audience/fern.git" + }, + "type": "commonjs", + "main": "./dist/cjs/index.js", + "module": "./dist/esm/index.mjs", + "types": "./dist/cjs/index.d.ts", + "exports": { + ".": { + "types": "./dist/cjs/index.d.ts", + "import": { + "types": "./dist/esm/index.d.mts", + "default": "./dist/esm/index.mjs" + }, + "require": { + "types": "./dist/cjs/index.d.ts", + "default": "./dist/cjs/index.js" + }, + "default": "./dist/cjs/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "reference.md", + "README.md", + "LICENSE" + ], + "scripts": { + "format": "biome format --write --skip-parse-errors --no-errors-on-unmatched --max-diagnostics=none", + "format:check": "biome format --skip-parse-errors --no-errors-on-unmatched --max-diagnostics=none", + "lint": "biome lint --skip-parse-errors --no-errors-on-unmatched --max-diagnostics=none", + "lint:fix": "biome lint --fix --unsafe --skip-parse-errors --no-errors-on-unmatched --max-diagnostics=none", + "check": "biome check --skip-parse-errors --no-errors-on-unmatched --max-diagnostics=none", + "check:fix": "biome check --fix --unsafe --skip-parse-errors --no-errors-on-unmatched --max-diagnostics=none", + "build": "pnpm build:cjs && pnpm build:esm", + "build:cjs": "tsc --project ./tsconfig.cjs.json", + "build:esm": "tsc --project ./tsconfig.esm.json && node scripts/rename-to-esm-files.js dist/esm", + "test": "vitest", + "test:unit": "vitest --project unit", + "test:wire": "vitest --project wire" + }, + "dependencies": {}, + "devDependencies": { + "webpack": "^5.97.1", + "ts-loader": "^9.5.1", + "vitest": "^3.2.4", + "msw": "2.11.2", + "@types/node": "^18.19.70", + "typescript": "~5.7.2", + "@biomejs/biome": "2.3.11" + }, + "browser": { + "fs": false, + "os": false, + "path": false, + "stream": false + }, + "packageManager": "pnpm@10.20.0", + "engines": { + "node": ">=18.0.0" + }, + "sideEffects": false +} diff --git a/seed/ts-sdk/webhook-audience/pnpm-workspace.yaml b/seed/ts-sdk/webhook-audience/pnpm-workspace.yaml new file mode 100644 index 000000000000..6e4c395107df --- /dev/null +++ b/seed/ts-sdk/webhook-audience/pnpm-workspace.yaml @@ -0,0 +1 @@ +packages: ['.'] \ No newline at end of file diff --git a/seed/ts-sdk/webhook-audience/scripts/rename-to-esm-files.js b/seed/ts-sdk/webhook-audience/scripts/rename-to-esm-files.js new file mode 100644 index 000000000000..dc1df1cbbacb --- /dev/null +++ b/seed/ts-sdk/webhook-audience/scripts/rename-to-esm-files.js @@ -0,0 +1,123 @@ +#!/usr/bin/env node + +const fs = require("fs").promises; +const path = require("path"); + +const extensionMap = { + ".js": ".mjs", + ".d.ts": ".d.mts", +}; +const oldExtensions = Object.keys(extensionMap); + +async function findFiles(rootPath) { + const files = []; + + async function scan(directory) { + const entries = await fs.readdir(directory, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(directory, entry.name); + + if (entry.isDirectory()) { + if (entry.name !== "node_modules" && !entry.name.startsWith(".")) { + await scan(fullPath); + } + } else if (entry.isFile()) { + if (oldExtensions.some((ext) => entry.name.endsWith(ext))) { + files.push(fullPath); + } + } + } + } + + await scan(rootPath); + return files; +} + +async function updateFiles(files) { + const updatedFiles = []; + for (const file of files) { + const updated = await updateFileContents(file); + updatedFiles.push(updated); + } + + console.log(`Updated imports in ${updatedFiles.length} files.`); +} + +async function updateFileContents(file) { + const content = await fs.readFile(file, "utf8"); + + let newContent = content; + // Update each extension type defined in the map + for (const [oldExt, newExt] of Object.entries(extensionMap)) { + // Handle static imports/exports + const staticRegex = new RegExp(`(import|export)(.+from\\s+['"])(\\.\\.?\\/[^'"]+)(\\${oldExt})(['"])`, "g"); + newContent = newContent.replace(staticRegex, `$1$2$3${newExt}$5`); + + // Handle dynamic imports (yield import, await import, regular import()) + const dynamicRegex = new RegExp( + `(yield\\s+import|await\\s+import|import)\\s*\\(\\s*['"](\\.\\.\?\\/[^'"]+)(\\${oldExt})['"]\\s*\\)`, + "g", + ); + newContent = newContent.replace(dynamicRegex, `$1("$2${newExt}")`); + } + + if (content !== newContent) { + await fs.writeFile(file, newContent, "utf8"); + return true; + } + return false; +} + +async function renameFiles(files) { + let counter = 0; + for (const file of files) { + const ext = oldExtensions.find((ext) => file.endsWith(ext)); + const newExt = extensionMap[ext]; + + if (newExt) { + const newPath = file.slice(0, -ext.length) + newExt; + await fs.rename(file, newPath); + counter++; + } + } + + console.log(`Renamed ${counter} files.`); +} + +async function main() { + try { + const targetDir = process.argv[2]; + if (!targetDir) { + console.error("Please provide a target directory"); + process.exit(1); + } + + const targetPath = path.resolve(targetDir); + const targetStats = await fs.stat(targetPath); + + if (!targetStats.isDirectory()) { + console.error("The provided path is not a directory"); + process.exit(1); + } + + console.log(`Scanning directory: ${targetDir}`); + + const files = await findFiles(targetDir); + + if (files.length === 0) { + console.log("No matching files found."); + process.exit(0); + } + + console.log(`Found ${files.length} files.`); + await updateFiles(files); + await renameFiles(files); + console.log("\nDone!"); + } catch (error) { + console.error("An error occurred:", error.message); + process.exit(1); + } +} + +main(); diff --git a/seed/ts-sdk/webhook-audience/snippet.json b/seed/ts-sdk/webhook-audience/snippet.json new file mode 100644 index 000000000000..0614251dd461 --- /dev/null +++ b/seed/ts-sdk/webhook-audience/snippet.json @@ -0,0 +1,4 @@ +{ + "endpoints": [], + "types": {} +} \ No newline at end of file diff --git a/seed/ts-sdk/webhook-audience/src/BaseClient.ts b/seed/ts-sdk/webhook-audience/src/BaseClient.ts new file mode 100644 index 000000000000..77e30951e3a8 --- /dev/null +++ b/seed/ts-sdk/webhook-audience/src/BaseClient.ts @@ -0,0 +1,59 @@ +// This file was auto-generated by Fern from our API Definition. + +import { mergeHeaders } from "./core/headers.js"; +import * as core from "./core/index.js"; + +export interface BaseClientOptions { + environment: core.Supplier; + /** Specify a custom URL to connect the client to. */ + baseUrl?: core.Supplier; + /** Additional headers to include in requests. */ + headers?: Record | null | undefined>; + /** The default maximum time to wait for a response in seconds. */ + timeoutInSeconds?: number; + /** The default number of times to retry the request. Defaults to 2. */ + maxRetries?: number; + /** Provide a custom fetch implementation. Useful for platforms that don't have a built-in fetch or need a custom implementation. */ + fetch?: typeof fetch; + /** Configure logging for the client. */ + logging?: core.logging.LogConfig | core.logging.Logger; +} + +export interface BaseRequestOptions { + /** The maximum time to wait for a response in seconds. */ + timeoutInSeconds?: number; + /** The number of times to retry the request. Defaults to 2. */ + maxRetries?: number; + /** A hook to abort the request. */ + abortSignal?: AbortSignal; + /** Additional query string parameters to include in the request. */ + queryParams?: Record; + /** Additional headers to include in the request. */ + headers?: Record | null | undefined>; +} + +export type NormalizedClientOptions = T & { + logging: core.logging.Logger; +}; + +export function normalizeClientOptions( + options: T, +): NormalizedClientOptions { + const headers = mergeHeaders( + { + "X-Fern-Language": "JavaScript", + "X-Fern-SDK-Name": "@fern/webhook-audience", + "X-Fern-SDK-Version": "0.0.1", + "User-Agent": "@fern/webhook-audience/0.0.1", + "X-Fern-Runtime": core.RUNTIME.type, + "X-Fern-Runtime-Version": core.RUNTIME.version, + }, + options?.headers, + ); + + return { + ...options, + logging: core.logging.createLogger(options?.logging), + headers, + } as NormalizedClientOptions; +} diff --git a/seed/ts-sdk/webhook-audience/src/api/index.ts b/seed/ts-sdk/webhook-audience/src/api/index.ts new file mode 100644 index 000000000000..2f88e3015854 --- /dev/null +++ b/seed/ts-sdk/webhook-audience/src/api/index.ts @@ -0,0 +1 @@ +export * from "./types/index.js"; diff --git a/seed/ts-sdk/webhook-audience/src/api/types/NoAudiencePayload.ts b/seed/ts-sdk/webhook-audience/src/api/types/NoAudiencePayload.ts new file mode 100644 index 000000000000..f9784a41dd34 --- /dev/null +++ b/seed/ts-sdk/webhook-audience/src/api/types/NoAudiencePayload.ts @@ -0,0 +1,5 @@ +// This file was auto-generated by Fern from our API Definition. + +export interface NoAudiencePayload { + data: string; +} diff --git a/seed/ts-sdk/webhook-audience/src/api/types/PrivatePayload.ts b/seed/ts-sdk/webhook-audience/src/api/types/PrivatePayload.ts new file mode 100644 index 000000000000..147412d04a74 --- /dev/null +++ b/seed/ts-sdk/webhook-audience/src/api/types/PrivatePayload.ts @@ -0,0 +1,5 @@ +// This file was auto-generated by Fern from our API Definition. + +export interface PrivatePayload { + secret: string; +} diff --git a/seed/ts-sdk/webhook-audience/src/api/types/PublicPayload.ts b/seed/ts-sdk/webhook-audience/src/api/types/PublicPayload.ts new file mode 100644 index 000000000000..826d1b031389 --- /dev/null +++ b/seed/ts-sdk/webhook-audience/src/api/types/PublicPayload.ts @@ -0,0 +1,5 @@ +// This file was auto-generated by Fern from our API Definition. + +export interface PublicPayload { + message: string; +} diff --git a/seed/ts-sdk/webhook-audience/src/api/types/index.ts b/seed/ts-sdk/webhook-audience/src/api/types/index.ts new file mode 100644 index 000000000000..30b9cc4bde40 --- /dev/null +++ b/seed/ts-sdk/webhook-audience/src/api/types/index.ts @@ -0,0 +1,3 @@ +export * from "./NoAudiencePayload.js"; +export * from "./PrivatePayload.js"; +export * from "./PublicPayload.js"; diff --git a/seed/ts-sdk/webhook-audience/src/core/exports.ts b/seed/ts-sdk/webhook-audience/src/core/exports.ts new file mode 100644 index 000000000000..69296d7100d6 --- /dev/null +++ b/seed/ts-sdk/webhook-audience/src/core/exports.ts @@ -0,0 +1 @@ +export * from "./logging/exports.js"; diff --git a/seed/ts-sdk/webhook-audience/src/core/fetcher/APIResponse.ts b/seed/ts-sdk/webhook-audience/src/core/fetcher/APIResponse.ts new file mode 100644 index 000000000000..97ab83c2b195 --- /dev/null +++ b/seed/ts-sdk/webhook-audience/src/core/fetcher/APIResponse.ts @@ -0,0 +1,23 @@ +import type { RawResponse } from "./RawResponse.js"; + +/** + * The response of an API call. + * It is a successful response or a failed response. + */ +export type APIResponse = SuccessfulResponse | FailedResponse; + +export interface SuccessfulResponse { + ok: true; + body: T; + /** + * @deprecated Use `rawResponse` instead + */ + headers?: Record; + rawResponse: RawResponse; +} + +export interface FailedResponse { + ok: false; + error: T; + rawResponse: RawResponse; +} diff --git a/seed/ts-sdk/webhook-audience/src/core/fetcher/BinaryResponse.ts b/seed/ts-sdk/webhook-audience/src/core/fetcher/BinaryResponse.ts new file mode 100644 index 000000000000..bca7f4c77981 --- /dev/null +++ b/seed/ts-sdk/webhook-audience/src/core/fetcher/BinaryResponse.ts @@ -0,0 +1,34 @@ +export type BinaryResponse = { + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/bodyUsed) */ + bodyUsed: Response["bodyUsed"]; + /** + * Returns a ReadableStream of the response body. + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/body) + */ + stream: () => Response["body"]; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/arrayBuffer) */ + arrayBuffer: () => ReturnType; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/blob) */ + blob: () => ReturnType; + /** + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/bytes) + * Some versions of the Fetch API may not support this method. + */ + bytes?(): ReturnType; +}; + +export function getBinaryResponse(response: Response): BinaryResponse { + const binaryResponse: BinaryResponse = { + get bodyUsed() { + return response.bodyUsed; + }, + stream: () => response.body, + arrayBuffer: response.arrayBuffer.bind(response), + blob: response.blob.bind(response), + }; + if ("bytes" in response && typeof response.bytes === "function") { + binaryResponse.bytes = response.bytes.bind(response); + } + + return binaryResponse; +} diff --git a/seed/ts-sdk/webhook-audience/src/core/fetcher/EndpointMetadata.ts b/seed/ts-sdk/webhook-audience/src/core/fetcher/EndpointMetadata.ts new file mode 100644 index 000000000000..998d68f5c20c --- /dev/null +++ b/seed/ts-sdk/webhook-audience/src/core/fetcher/EndpointMetadata.ts @@ -0,0 +1,13 @@ +export type SecuritySchemeKey = string; +/** + * A collection of security schemes, where the key is the name of the security scheme and the value is the list of scopes required for that scheme. + * All schemes in the collection must be satisfied for authentication to be successful. + */ +export type SecuritySchemeCollection = Record; +export type AuthScope = string; +export type EndpointMetadata = { + /** + * An array of security scheme collections. Each collection represents an alternative way to authenticate. + */ + security?: SecuritySchemeCollection[]; +}; diff --git a/seed/ts-sdk/webhook-audience/src/core/fetcher/EndpointSupplier.ts b/seed/ts-sdk/webhook-audience/src/core/fetcher/EndpointSupplier.ts new file mode 100644 index 000000000000..aad81f0d9040 --- /dev/null +++ b/seed/ts-sdk/webhook-audience/src/core/fetcher/EndpointSupplier.ts @@ -0,0 +1,14 @@ +import type { EndpointMetadata } from "./EndpointMetadata.js"; +import type { Supplier } from "./Supplier.js"; + +type EndpointSupplierFn = (arg: { endpointMetadata?: EndpointMetadata }) => T | Promise; +export type EndpointSupplier = Supplier | EndpointSupplierFn; +export const EndpointSupplier = { + get: async (supplier: EndpointSupplier, arg: { endpointMetadata?: EndpointMetadata }): Promise => { + if (typeof supplier === "function") { + return (supplier as EndpointSupplierFn)(arg); + } else { + return supplier; + } + }, +}; diff --git a/seed/ts-sdk/webhook-audience/src/core/fetcher/Fetcher.ts b/seed/ts-sdk/webhook-audience/src/core/fetcher/Fetcher.ts new file mode 100644 index 000000000000..45cae32b23c1 --- /dev/null +++ b/seed/ts-sdk/webhook-audience/src/core/fetcher/Fetcher.ts @@ -0,0 +1,391 @@ +import { toJson } from "../json.js"; +import { createLogger, type LogConfig, type Logger } from "../logging/logger.js"; +import type { APIResponse } from "./APIResponse.js"; +import { createRequestUrl } from "./createRequestUrl.js"; +import type { EndpointMetadata } from "./EndpointMetadata.js"; +import { EndpointSupplier } from "./EndpointSupplier.js"; +import { getErrorResponseBody } from "./getErrorResponseBody.js"; +import { getFetchFn } from "./getFetchFn.js"; +import { getRequestBody } from "./getRequestBody.js"; +import { getResponseBody } from "./getResponseBody.js"; +import { Headers } from "./Headers.js"; +import { makeRequest } from "./makeRequest.js"; +import { abortRawResponse, toRawResponse, unknownRawResponse } from "./RawResponse.js"; +import { requestWithRetries } from "./requestWithRetries.js"; + +export type FetchFunction = (args: Fetcher.Args) => Promise>; + +export declare namespace Fetcher { + export interface Args { + url: string; + method: string; + contentType?: string; + headers?: Record; + queryParameters?: Record; + body?: unknown; + timeoutMs?: number; + maxRetries?: number; + withCredentials?: boolean; + abortSignal?: AbortSignal; + requestType?: "json" | "file" | "bytes" | "form" | "other"; + responseType?: "json" | "blob" | "sse" | "streaming" | "text" | "arrayBuffer" | "binary-response"; + duplex?: "half"; + endpointMetadata?: EndpointMetadata; + fetchFn?: typeof fetch; + logging?: LogConfig | Logger; + } + + export type Error = FailedStatusCodeError | NonJsonError | BodyIsNullError | TimeoutError | UnknownError; + + export interface FailedStatusCodeError { + reason: "status-code"; + statusCode: number; + body: unknown; + } + + export interface NonJsonError { + reason: "non-json"; + statusCode: number; + rawBody: string; + } + + export interface BodyIsNullError { + reason: "body-is-null"; + statusCode: number; + } + + export interface TimeoutError { + reason: "timeout"; + } + + export interface UnknownError { + reason: "unknown"; + errorMessage: string; + } +} + +const SENSITIVE_HEADERS = new Set([ + "authorization", + "www-authenticate", + "x-api-key", + "api-key", + "apikey", + "x-api-token", + "x-auth-token", + "auth-token", + "cookie", + "set-cookie", + "proxy-authorization", + "proxy-authenticate", + "x-csrf-token", + "x-xsrf-token", + "x-session-token", + "x-access-token", +]); + +function redactHeaders(headers: Headers | Record): Record { + const filtered: Record = {}; + for (const [key, value] of headers instanceof Headers ? headers.entries() : Object.entries(headers)) { + if (SENSITIVE_HEADERS.has(key.toLowerCase())) { + filtered[key] = "[REDACTED]"; + } else { + filtered[key] = value; + } + } + return filtered; +} + +const SENSITIVE_QUERY_PARAMS = new Set([ + "api_key", + "api-key", + "apikey", + "token", + "access_token", + "access-token", + "auth_token", + "auth-token", + "password", + "passwd", + "secret", + "api_secret", + "api-secret", + "apisecret", + "key", + "session", + "session_id", + "session-id", +]); + +function redactQueryParameters(queryParameters?: Record): Record | undefined { + if (queryParameters == null) { + return queryParameters; + } + const redacted: Record = {}; + for (const [key, value] of Object.entries(queryParameters)) { + if (SENSITIVE_QUERY_PARAMS.has(key.toLowerCase())) { + redacted[key] = "[REDACTED]"; + } else { + redacted[key] = value; + } + } + return redacted; +} + +function redactUrl(url: string): string { + const protocolIndex = url.indexOf("://"); + if (protocolIndex === -1) return url; + + const afterProtocol = protocolIndex + 3; + + // Find the first delimiter that marks the end of the authority section + const pathStart = url.indexOf("/", afterProtocol); + let queryStart = url.indexOf("?", afterProtocol); + let fragmentStart = url.indexOf("#", afterProtocol); + + const firstDelimiter = Math.min( + pathStart === -1 ? url.length : pathStart, + queryStart === -1 ? url.length : queryStart, + fragmentStart === -1 ? url.length : fragmentStart, + ); + + // Find the LAST @ before the delimiter (handles multiple @ in credentials) + let atIndex = -1; + for (let i = afterProtocol; i < firstDelimiter; i++) { + if (url[i] === "@") { + atIndex = i; + } + } + + if (atIndex !== -1) { + url = `${url.slice(0, afterProtocol)}[REDACTED]@${url.slice(atIndex + 1)}`; + } + + // Recalculate queryStart since url might have changed + queryStart = url.indexOf("?"); + if (queryStart === -1) return url; + + fragmentStart = url.indexOf("#", queryStart); + const queryEnd = fragmentStart !== -1 ? fragmentStart : url.length; + const queryString = url.slice(queryStart + 1, queryEnd); + + if (queryString.length === 0) return url; + + // FAST PATH: Quick check if any sensitive keywords present + // Using indexOf is faster than regex for simple substring matching + const lower = queryString.toLowerCase(); + const hasSensitive = + lower.includes("token") || + lower.includes("key") || + lower.includes("password") || + lower.includes("passwd") || + lower.includes("secret") || + lower.includes("session") || + lower.includes("auth"); + + if (!hasSensitive) { + return url; + } + + // SLOW PATH: Parse and redact + const redactedParams: string[] = []; + const params = queryString.split("&"); + + for (const param of params) { + const equalIndex = param.indexOf("="); + if (equalIndex === -1) { + redactedParams.push(param); + continue; + } + + const key = param.slice(0, equalIndex); + let shouldRedact = SENSITIVE_QUERY_PARAMS.has(key.toLowerCase()); + + if (!shouldRedact && key.includes("%")) { + try { + const decodedKey = decodeURIComponent(key); + shouldRedact = SENSITIVE_QUERY_PARAMS.has(decodedKey.toLowerCase()); + } catch {} + } + + redactedParams.push(shouldRedact ? `${key}=[REDACTED]` : param); + } + + return url.slice(0, queryStart + 1) + redactedParams.join("&") + url.slice(queryEnd); +} + +async function getHeaders(args: Fetcher.Args): Promise { + const newHeaders: Headers = new Headers(); + + newHeaders.set( + "Accept", + args.responseType === "json" ? "application/json" : args.responseType === "text" ? "text/plain" : "*/*", + ); + if (args.body !== undefined && args.contentType != null) { + newHeaders.set("Content-Type", args.contentType); + } + + if (args.headers == null) { + return newHeaders; + } + + for (const [key, value] of Object.entries(args.headers)) { + const result = await EndpointSupplier.get(value, { endpointMetadata: args.endpointMetadata ?? {} }); + if (typeof result === "string") { + newHeaders.set(key, result); + continue; + } + if (result == null) { + continue; + } + newHeaders.set(key, `${result}`); + } + return newHeaders; +} + +export async function fetcherImpl(args: Fetcher.Args): Promise> { + const url = createRequestUrl(args.url, args.queryParameters); + const requestBody: BodyInit | undefined = await getRequestBody({ + body: args.body, + type: args.requestType ?? "other", + }); + const fetchFn = args.fetchFn ?? (await getFetchFn()); + const headers = await getHeaders(args); + const logger = createLogger(args.logging); + + if (logger.isDebug()) { + const metadata = { + method: args.method, + url: redactUrl(url), + headers: redactHeaders(headers), + queryParameters: redactQueryParameters(args.queryParameters), + hasBody: requestBody != null, + }; + logger.debug("Making HTTP request", metadata); + } + + try { + const response = await requestWithRetries( + async () => + makeRequest( + fetchFn, + url, + args.method, + headers, + requestBody, + args.timeoutMs, + args.abortSignal, + args.withCredentials, + args.duplex, + ), + args.maxRetries, + ); + + if (response.status >= 200 && response.status < 400) { + if (logger.isDebug()) { + const metadata = { + method: args.method, + url: redactUrl(url), + statusCode: response.status, + responseHeaders: redactHeaders(response.headers), + }; + logger.debug("HTTP request succeeded", metadata); + } + const body = await getResponseBody(response, args.responseType); + return { + ok: true, + body: body as R, + headers: response.headers, + rawResponse: toRawResponse(response), + }; + } else { + if (logger.isError()) { + const metadata = { + method: args.method, + url: redactUrl(url), + statusCode: response.status, + responseHeaders: redactHeaders(Object.fromEntries(response.headers.entries())), + }; + logger.error("HTTP request failed with error status", metadata); + } + return { + ok: false, + error: { + reason: "status-code", + statusCode: response.status, + body: await getErrorResponseBody(response), + }, + rawResponse: toRawResponse(response), + }; + } + } catch (error) { + if (args.abortSignal?.aborted) { + if (logger.isError()) { + const metadata = { + method: args.method, + url: redactUrl(url), + }; + logger.error("HTTP request was aborted", metadata); + } + return { + ok: false, + error: { + reason: "unknown", + errorMessage: "The user aborted a request", + }, + rawResponse: abortRawResponse, + }; + } else if (error instanceof Error && error.name === "AbortError") { + if (logger.isError()) { + const metadata = { + method: args.method, + url: redactUrl(url), + timeoutMs: args.timeoutMs, + }; + logger.error("HTTP request timed out", metadata); + } + return { + ok: false, + error: { + reason: "timeout", + }, + rawResponse: abortRawResponse, + }; + } else if (error instanceof Error) { + if (logger.isError()) { + const metadata = { + method: args.method, + url: redactUrl(url), + errorMessage: error.message, + }; + logger.error("HTTP request failed with error", metadata); + } + return { + ok: false, + error: { + reason: "unknown", + errorMessage: error.message, + }, + rawResponse: unknownRawResponse, + }; + } + + if (logger.isError()) { + const metadata = { + method: args.method, + url: redactUrl(url), + error: toJson(error), + }; + logger.error("HTTP request failed with unknown error", metadata); + } + return { + ok: false, + error: { + reason: "unknown", + errorMessage: toJson(error), + }, + rawResponse: unknownRawResponse, + }; + } +} + +export const fetcher: FetchFunction = fetcherImpl; diff --git a/seed/ts-sdk/webhook-audience/src/core/fetcher/Headers.ts b/seed/ts-sdk/webhook-audience/src/core/fetcher/Headers.ts new file mode 100644 index 000000000000..af841aa24f55 --- /dev/null +++ b/seed/ts-sdk/webhook-audience/src/core/fetcher/Headers.ts @@ -0,0 +1,93 @@ +let Headers: typeof globalThis.Headers; + +if (typeof globalThis.Headers !== "undefined") { + Headers = globalThis.Headers; +} else { + Headers = class Headers implements Headers { + private headers: Map; + + constructor(init?: HeadersInit) { + this.headers = new Map(); + + if (init) { + if (init instanceof Headers) { + init.forEach((value, key) => this.append(key, value)); + } else if (Array.isArray(init)) { + for (const [key, value] of init) { + if (typeof key === "string" && typeof value === "string") { + this.append(key, value); + } else { + throw new TypeError("Each header entry must be a [string, string] tuple"); + } + } + } else { + for (const [key, value] of Object.entries(init)) { + if (typeof value === "string") { + this.append(key, value); + } else { + throw new TypeError("Header values must be strings"); + } + } + } + } + } + + append(name: string, value: string): void { + const key = name.toLowerCase(); + const existing = this.headers.get(key) || []; + this.headers.set(key, [...existing, value]); + } + + delete(name: string): void { + const key = name.toLowerCase(); + this.headers.delete(key); + } + + get(name: string): string | null { + const key = name.toLowerCase(); + const values = this.headers.get(key); + return values ? values.join(", ") : null; + } + + has(name: string): boolean { + const key = name.toLowerCase(); + return this.headers.has(key); + } + + set(name: string, value: string): void { + const key = name.toLowerCase(); + this.headers.set(key, [value]); + } + + forEach(callbackfn: (value: string, key: string, parent: Headers) => void, thisArg?: unknown): void { + const boundCallback = thisArg ? callbackfn.bind(thisArg) : callbackfn; + this.headers.forEach((values, key) => boundCallback(values.join(", "), key, this)); + } + + getSetCookie(): string[] { + return this.headers.get("set-cookie") || []; + } + + *entries(): HeadersIterator<[string, string]> { + for (const [key, values] of this.headers.entries()) { + yield [key, values.join(", ")]; + } + } + + *keys(): HeadersIterator { + yield* this.headers.keys(); + } + + *values(): HeadersIterator { + for (const values of this.headers.values()) { + yield values.join(", "); + } + } + + [Symbol.iterator](): HeadersIterator<[string, string]> { + return this.entries(); + } + }; +} + +export { Headers }; diff --git a/seed/ts-sdk/webhook-audience/src/core/fetcher/HttpResponsePromise.ts b/seed/ts-sdk/webhook-audience/src/core/fetcher/HttpResponsePromise.ts new file mode 100644 index 000000000000..692ca7d795f0 --- /dev/null +++ b/seed/ts-sdk/webhook-audience/src/core/fetcher/HttpResponsePromise.ts @@ -0,0 +1,116 @@ +import type { WithRawResponse } from "./RawResponse.js"; + +/** + * A promise that returns the parsed response and lets you retrieve the raw response too. + */ +export class HttpResponsePromise extends Promise { + private innerPromise: Promise>; + private unwrappedPromise: Promise | undefined; + + private constructor(promise: Promise>) { + // Initialize with a no-op to avoid premature parsing + super((resolve) => { + resolve(undefined as unknown as T); + }); + this.innerPromise = promise; + } + + /** + * Creates an `HttpResponsePromise` from a function that returns a promise. + * + * @param fn - A function that returns a promise resolving to a `WithRawResponse` object. + * @param args - Arguments to pass to the function. + * @returns An `HttpResponsePromise` instance. + */ + public static fromFunction Promise>, T>( + fn: F, + ...args: Parameters + ): HttpResponsePromise { + return new HttpResponsePromise(fn(...args)); + } + + /** + * Creates a function that returns an `HttpResponsePromise` from a function that returns a promise. + * + * @param fn - A function that returns a promise resolving to a `WithRawResponse` object. + * @returns A function that returns an `HttpResponsePromise` instance. + */ + public static interceptFunction< + F extends (...args: never[]) => Promise>, + T = Awaited>["data"], + >(fn: F): (...args: Parameters) => HttpResponsePromise { + return (...args: Parameters): HttpResponsePromise => { + return HttpResponsePromise.fromPromise(fn(...args)); + }; + } + + /** + * Creates an `HttpResponsePromise` from an existing promise. + * + * @param promise - A promise resolving to a `WithRawResponse` object. + * @returns An `HttpResponsePromise` instance. + */ + public static fromPromise(promise: Promise>): HttpResponsePromise { + return new HttpResponsePromise(promise); + } + + /** + * Creates an `HttpResponsePromise` from an executor function. + * + * @param executor - A function that takes resolve and reject callbacks to create a promise. + * @returns An `HttpResponsePromise` instance. + */ + public static fromExecutor( + executor: (resolve: (value: WithRawResponse) => void, reject: (reason?: unknown) => void) => void, + ): HttpResponsePromise { + const promise = new Promise>(executor); + return new HttpResponsePromise(promise); + } + + /** + * Creates an `HttpResponsePromise` from a resolved result. + * + * @param result - A `WithRawResponse` object to resolve immediately. + * @returns An `HttpResponsePromise` instance. + */ + public static fromResult(result: WithRawResponse): HttpResponsePromise { + const promise = Promise.resolve(result); + return new HttpResponsePromise(promise); + } + + private unwrap(): Promise { + if (!this.unwrappedPromise) { + this.unwrappedPromise = this.innerPromise.then(({ data }) => data); + } + return this.unwrappedPromise; + } + + /** @inheritdoc */ + public override then( + onfulfilled?: ((value: T) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, + ): Promise { + return this.unwrap().then(onfulfilled, onrejected); + } + + /** @inheritdoc */ + public override catch( + onrejected?: ((reason: unknown) => TResult | PromiseLike) | null, + ): Promise { + return this.unwrap().catch(onrejected); + } + + /** @inheritdoc */ + public override finally(onfinally?: (() => void) | null): Promise { + return this.unwrap().finally(onfinally); + } + + /** + * Retrieves the data and raw response. + * + * @returns A promise resolving to a `WithRawResponse` object. + */ + public async withRawResponse(): Promise> { + return await this.innerPromise; + } +} diff --git a/seed/ts-sdk/webhook-audience/src/core/fetcher/RawResponse.ts b/seed/ts-sdk/webhook-audience/src/core/fetcher/RawResponse.ts new file mode 100644 index 000000000000..37fb44e2aa99 --- /dev/null +++ b/seed/ts-sdk/webhook-audience/src/core/fetcher/RawResponse.ts @@ -0,0 +1,61 @@ +import { Headers } from "./Headers.js"; + +/** + * The raw response from the fetch call excluding the body. + */ +export type RawResponse = Omit< + { + [K in keyof Response as Response[K] extends Function ? never : K]: Response[K]; // strips out functions + }, + "ok" | "body" | "bodyUsed" +>; // strips out body and bodyUsed + +/** + * A raw response indicating that the request was aborted. + */ +export const abortRawResponse: RawResponse = { + headers: new Headers(), + redirected: false, + status: 499, + statusText: "Client Closed Request", + type: "error", + url: "", +} as const; + +/** + * A raw response indicating an unknown error. + */ +export const unknownRawResponse: RawResponse = { + headers: new Headers(), + redirected: false, + status: 0, + statusText: "Unknown Error", + type: "error", + url: "", +} as const; + +/** + * Converts a `RawResponse` object into a `RawResponse` by extracting its properties, + * excluding the `body` and `bodyUsed` fields. + * + * @param response - The `RawResponse` object to convert. + * @returns A `RawResponse` object containing the extracted properties of the input response. + */ +export function toRawResponse(response: Response): RawResponse { + return { + headers: response.headers, + redirected: response.redirected, + status: response.status, + statusText: response.statusText, + type: response.type, + url: response.url, + }; +} + +/** + * Creates a `RawResponse` from a standard `Response` object. + */ +export interface WithRawResponse { + readonly data: T; + readonly rawResponse: RawResponse; +} diff --git a/seed/ts-sdk/webhook-audience/src/core/fetcher/Supplier.ts b/seed/ts-sdk/webhook-audience/src/core/fetcher/Supplier.ts new file mode 100644 index 000000000000..867c931c02f4 --- /dev/null +++ b/seed/ts-sdk/webhook-audience/src/core/fetcher/Supplier.ts @@ -0,0 +1,11 @@ +export type Supplier = T | Promise | (() => T | Promise); + +export const Supplier = { + get: async (supplier: Supplier): Promise => { + if (typeof supplier === "function") { + return (supplier as () => T)(); + } else { + return supplier; + } + }, +}; diff --git a/seed/ts-sdk/webhook-audience/src/core/fetcher/createRequestUrl.ts b/seed/ts-sdk/webhook-audience/src/core/fetcher/createRequestUrl.ts new file mode 100644 index 000000000000..88e13265e112 --- /dev/null +++ b/seed/ts-sdk/webhook-audience/src/core/fetcher/createRequestUrl.ts @@ -0,0 +1,6 @@ +import { toQueryString } from "../url/qs.js"; + +export function createRequestUrl(baseUrl: string, queryParameters?: Record): string { + const queryString = toQueryString(queryParameters, { arrayFormat: "repeat" }); + return queryString ? `${baseUrl}?${queryString}` : baseUrl; +} diff --git a/seed/ts-sdk/webhook-audience/src/core/fetcher/getErrorResponseBody.ts b/seed/ts-sdk/webhook-audience/src/core/fetcher/getErrorResponseBody.ts new file mode 100644 index 000000000000..7cf4e623c2f5 --- /dev/null +++ b/seed/ts-sdk/webhook-audience/src/core/fetcher/getErrorResponseBody.ts @@ -0,0 +1,33 @@ +import { fromJson } from "../json.js"; +import { getResponseBody } from "./getResponseBody.js"; + +export async function getErrorResponseBody(response: Response): Promise { + let contentType = response.headers.get("Content-Type")?.toLowerCase(); + if (contentType == null || contentType.length === 0) { + return getResponseBody(response); + } + + if (contentType.indexOf(";") !== -1) { + contentType = contentType.split(";")[0]?.trim() ?? ""; + } + switch (contentType) { + case "application/hal+json": + case "application/json": + case "application/ld+json": + case "application/problem+json": + case "application/vnd.api+json": + case "text/json": { + const text = await response.text(); + return text.length > 0 ? fromJson(text) : undefined; + } + default: + if (contentType.startsWith("application/vnd.") && contentType.endsWith("+json")) { + const text = await response.text(); + return text.length > 0 ? fromJson(text) : undefined; + } + + // Fallback to plain text if content type is not recognized + // Even if no body is present, the response will be an empty string + return await response.text(); + } +} diff --git a/seed/ts-sdk/webhook-audience/src/core/fetcher/getFetchFn.ts b/seed/ts-sdk/webhook-audience/src/core/fetcher/getFetchFn.ts new file mode 100644 index 000000000000..9f845b956392 --- /dev/null +++ b/seed/ts-sdk/webhook-audience/src/core/fetcher/getFetchFn.ts @@ -0,0 +1,3 @@ +export async function getFetchFn(): Promise { + return fetch; +} diff --git a/seed/ts-sdk/webhook-audience/src/core/fetcher/getHeader.ts b/seed/ts-sdk/webhook-audience/src/core/fetcher/getHeader.ts new file mode 100644 index 000000000000..50f922b0e87f --- /dev/null +++ b/seed/ts-sdk/webhook-audience/src/core/fetcher/getHeader.ts @@ -0,0 +1,8 @@ +export function getHeader(headers: Record, header: string): string | undefined { + for (const [headerKey, headerValue] of Object.entries(headers)) { + if (headerKey.toLowerCase() === header.toLowerCase()) { + return headerValue; + } + } + return undefined; +} diff --git a/seed/ts-sdk/webhook-audience/src/core/fetcher/getRequestBody.ts b/seed/ts-sdk/webhook-audience/src/core/fetcher/getRequestBody.ts new file mode 100644 index 000000000000..91d9d81f50e5 --- /dev/null +++ b/seed/ts-sdk/webhook-audience/src/core/fetcher/getRequestBody.ts @@ -0,0 +1,20 @@ +import { toJson } from "../json.js"; +import { toQueryString } from "../url/qs.js"; + +export declare namespace GetRequestBody { + interface Args { + body: unknown; + type: "json" | "file" | "bytes" | "form" | "other"; + } +} + +export async function getRequestBody({ body, type }: GetRequestBody.Args): Promise { + if (type === "form") { + return toQueryString(body, { arrayFormat: "repeat", encode: true }); + } + if (type.includes("json")) { + return toJson(body); + } else { + return body as BodyInit; + } +} diff --git a/seed/ts-sdk/webhook-audience/src/core/fetcher/getResponseBody.ts b/seed/ts-sdk/webhook-audience/src/core/fetcher/getResponseBody.ts new file mode 100644 index 000000000000..708d55728f2b --- /dev/null +++ b/seed/ts-sdk/webhook-audience/src/core/fetcher/getResponseBody.ts @@ -0,0 +1,58 @@ +import { fromJson } from "../json.js"; +import { getBinaryResponse } from "./BinaryResponse.js"; + +export async function getResponseBody(response: Response, responseType?: string): Promise { + switch (responseType) { + case "binary-response": + return getBinaryResponse(response); + case "blob": + return await response.blob(); + case "arrayBuffer": + return await response.arrayBuffer(); + case "sse": + if (response.body == null) { + return { + ok: false, + error: { + reason: "body-is-null", + statusCode: response.status, + }, + }; + } + return response.body; + case "streaming": + if (response.body == null) { + return { + ok: false, + error: { + reason: "body-is-null", + statusCode: response.status, + }, + }; + } + + return response.body; + + case "text": + return await response.text(); + } + + // if responseType is "json" or not specified, try to parse as JSON + const text = await response.text(); + if (text.length > 0) { + try { + const responseBody = fromJson(text); + return responseBody; + } catch (_err) { + return { + ok: false, + error: { + reason: "non-json", + statusCode: response.status, + rawBody: text, + }, + }; + } + } + return undefined; +} diff --git a/seed/ts-sdk/webhook-audience/src/core/fetcher/index.ts b/seed/ts-sdk/webhook-audience/src/core/fetcher/index.ts new file mode 100644 index 000000000000..c3bc6da20f49 --- /dev/null +++ b/seed/ts-sdk/webhook-audience/src/core/fetcher/index.ts @@ -0,0 +1,11 @@ +export type { APIResponse } from "./APIResponse.js"; +export type { BinaryResponse } from "./BinaryResponse.js"; +export type { EndpointMetadata } from "./EndpointMetadata.js"; +export { EndpointSupplier } from "./EndpointSupplier.js"; +export type { Fetcher, FetchFunction } from "./Fetcher.js"; +export { fetcher } from "./Fetcher.js"; +export { getHeader } from "./getHeader.js"; +export { HttpResponsePromise } from "./HttpResponsePromise.js"; +export type { RawResponse, WithRawResponse } from "./RawResponse.js"; +export { abortRawResponse, toRawResponse, unknownRawResponse } from "./RawResponse.js"; +export { Supplier } from "./Supplier.js"; diff --git a/seed/ts-sdk/webhook-audience/src/core/fetcher/makeRequest.ts b/seed/ts-sdk/webhook-audience/src/core/fetcher/makeRequest.ts new file mode 100644 index 000000000000..921565eb0063 --- /dev/null +++ b/seed/ts-sdk/webhook-audience/src/core/fetcher/makeRequest.ts @@ -0,0 +1,42 @@ +import { anySignal, getTimeoutSignal } from "./signals.js"; + +export const makeRequest = async ( + fetchFn: (url: string, init: RequestInit) => Promise, + url: string, + method: string, + headers: Headers | Record, + requestBody: BodyInit | undefined, + timeoutMs?: number, + abortSignal?: AbortSignal, + withCredentials?: boolean, + duplex?: "half", +): Promise => { + const signals: AbortSignal[] = []; + + let timeoutAbortId: ReturnType | undefined; + if (timeoutMs != null) { + const { signal, abortId } = getTimeoutSignal(timeoutMs); + timeoutAbortId = abortId; + signals.push(signal); + } + + if (abortSignal != null) { + signals.push(abortSignal); + } + const newSignals = anySignal(signals); + const response = await fetchFn(url, { + method: method, + headers, + body: requestBody, + signal: newSignals, + credentials: withCredentials ? "include" : undefined, + // @ts-ignore + duplex, + }); + + if (timeoutAbortId != null) { + clearTimeout(timeoutAbortId); + } + + return response; +}; diff --git a/seed/ts-sdk/webhook-audience/src/core/fetcher/requestWithRetries.ts b/seed/ts-sdk/webhook-audience/src/core/fetcher/requestWithRetries.ts new file mode 100644 index 000000000000..1f689688c4b2 --- /dev/null +++ b/seed/ts-sdk/webhook-audience/src/core/fetcher/requestWithRetries.ts @@ -0,0 +1,64 @@ +const INITIAL_RETRY_DELAY = 1000; // in milliseconds +const MAX_RETRY_DELAY = 60000; // in milliseconds +const DEFAULT_MAX_RETRIES = 2; +const JITTER_FACTOR = 0.2; // 20% random jitter + +function addPositiveJitter(delay: number): number { + const jitterMultiplier = 1 + Math.random() * JITTER_FACTOR; + return delay * jitterMultiplier; +} + +function addSymmetricJitter(delay: number): number { + const jitterMultiplier = 1 + (Math.random() - 0.5) * JITTER_FACTOR; + return delay * jitterMultiplier; +} + +function getRetryDelayFromHeaders(response: Response, retryAttempt: number): number { + const retryAfter = response.headers.get("Retry-After"); + if (retryAfter) { + const retryAfterSeconds = parseInt(retryAfter, 10); + if (!Number.isNaN(retryAfterSeconds) && retryAfterSeconds > 0) { + return Math.min(retryAfterSeconds * 1000, MAX_RETRY_DELAY); + } + + const retryAfterDate = new Date(retryAfter); + if (!Number.isNaN(retryAfterDate.getTime())) { + const delay = retryAfterDate.getTime() - Date.now(); + if (delay > 0) { + return Math.min(Math.max(delay, 0), MAX_RETRY_DELAY); + } + } + } + + const rateLimitReset = response.headers.get("X-RateLimit-Reset"); + if (rateLimitReset) { + const resetTime = parseInt(rateLimitReset, 10); + if (!Number.isNaN(resetTime)) { + const delay = resetTime * 1000 - Date.now(); + if (delay > 0) { + return addPositiveJitter(Math.min(delay, MAX_RETRY_DELAY)); + } + } + } + + return addSymmetricJitter(Math.min(INITIAL_RETRY_DELAY * 2 ** retryAttempt, MAX_RETRY_DELAY)); +} + +export async function requestWithRetries( + requestFn: () => Promise, + maxRetries: number = DEFAULT_MAX_RETRIES, +): Promise { + let response: Response = await requestFn(); + + for (let i = 0; i < maxRetries; ++i) { + if ([408, 429].includes(response.status) || response.status >= 500) { + const delay = getRetryDelayFromHeaders(response, i); + + await new Promise((resolve) => setTimeout(resolve, delay)); + response = await requestFn(); + } else { + break; + } + } + return response!; +} diff --git a/seed/ts-sdk/webhook-audience/src/core/fetcher/signals.ts b/seed/ts-sdk/webhook-audience/src/core/fetcher/signals.ts new file mode 100644 index 000000000000..7bd3757ec3a7 --- /dev/null +++ b/seed/ts-sdk/webhook-audience/src/core/fetcher/signals.ts @@ -0,0 +1,26 @@ +const TIMEOUT = "timeout"; + +export function getTimeoutSignal(timeoutMs: number): { signal: AbortSignal; abortId: ReturnType } { + const controller = new AbortController(); + const abortId = setTimeout(() => controller.abort(TIMEOUT), timeoutMs); + return { signal: controller.signal, abortId }; +} + +export function anySignal(...args: AbortSignal[] | [AbortSignal[]]): AbortSignal { + const signals = (args.length === 1 && Array.isArray(args[0]) ? args[0] : args) as AbortSignal[]; + + const controller = new AbortController(); + + for (const signal of signals) { + if (signal.aborted) { + controller.abort((signal as any)?.reason); + break; + } + + signal.addEventListener("abort", () => controller.abort((signal as any)?.reason), { + signal: controller.signal, + }); + } + + return controller.signal; +} diff --git a/seed/ts-sdk/webhook-audience/src/core/headers.ts b/seed/ts-sdk/webhook-audience/src/core/headers.ts new file mode 100644 index 000000000000..be45c4552a35 --- /dev/null +++ b/seed/ts-sdk/webhook-audience/src/core/headers.ts @@ -0,0 +1,33 @@ +export function mergeHeaders(...headersArray: (Record | null | undefined)[]): Record { + const result: Record = {}; + + for (const [key, value] of headersArray + .filter((headers) => headers != null) + .flatMap((headers) => Object.entries(headers))) { + const insensitiveKey = key.toLowerCase(); + if (value != null) { + result[insensitiveKey] = value; + } else if (insensitiveKey in result) { + delete result[insensitiveKey]; + } + } + + return result; +} + +export function mergeOnlyDefinedHeaders( + ...headersArray: (Record | null | undefined)[] +): Record { + const result: Record = {}; + + for (const [key, value] of headersArray + .filter((headers) => headers != null) + .flatMap((headers) => Object.entries(headers))) { + const insensitiveKey = key.toLowerCase(); + if (value != null) { + result[insensitiveKey] = value; + } + } + + return result; +} diff --git a/seed/ts-sdk/webhook-audience/src/core/index.ts b/seed/ts-sdk/webhook-audience/src/core/index.ts new file mode 100644 index 000000000000..afa8351fcf85 --- /dev/null +++ b/seed/ts-sdk/webhook-audience/src/core/index.ts @@ -0,0 +1,4 @@ +export * from "./fetcher/index.js"; +export * as logging from "./logging/index.js"; +export * from "./runtime/index.js"; +export * as url from "./url/index.js"; diff --git a/seed/ts-sdk/webhook-audience/src/core/json.ts b/seed/ts-sdk/webhook-audience/src/core/json.ts new file mode 100644 index 000000000000..c052f3249f4f --- /dev/null +++ b/seed/ts-sdk/webhook-audience/src/core/json.ts @@ -0,0 +1,27 @@ +/** + * Serialize a value to JSON + * @param value A JavaScript value, usually an object or array, to be converted. + * @param replacer A function that transforms the results. + * @param space Adds indentation, white space, and line break characters to the return-value JSON text to make it easier to read. + * @returns JSON string + */ +export const toJson = ( + value: unknown, + replacer?: (this: unknown, key: string, value: unknown) => unknown, + space?: string | number, +): string => { + return JSON.stringify(value, replacer, space); +}; + +/** + * Parse JSON string to object, array, or other type + * @param text A valid JSON string. + * @param reviver A function that transforms the results. This function is called for each member of the object. If a member contains nested objects, the nested objects are transformed before the parent object is. + * @returns Parsed object, array, or other type + */ +export function fromJson( + text: string, + reviver?: (this: unknown, key: string, value: unknown) => unknown, +): T { + return JSON.parse(text, reviver); +} diff --git a/seed/ts-sdk/webhook-audience/src/core/logging/exports.ts b/seed/ts-sdk/webhook-audience/src/core/logging/exports.ts new file mode 100644 index 000000000000..88f6c00db0cf --- /dev/null +++ b/seed/ts-sdk/webhook-audience/src/core/logging/exports.ts @@ -0,0 +1,19 @@ +import * as logger from "./logger.js"; + +export namespace logging { + /** + * Configuration for logger instances. + */ + export type LogConfig = logger.LogConfig; + export type LogLevel = logger.LogLevel; + export const LogLevel: typeof logger.LogLevel = logger.LogLevel; + export type ILogger = logger.ILogger; + /** + * Console logger implementation that outputs to the console. + */ + export type ConsoleLogger = logger.ConsoleLogger; + /** + * Console logger implementation that outputs to the console. + */ + export const ConsoleLogger: typeof logger.ConsoleLogger = logger.ConsoleLogger; +} diff --git a/seed/ts-sdk/webhook-audience/src/core/logging/index.ts b/seed/ts-sdk/webhook-audience/src/core/logging/index.ts new file mode 100644 index 000000000000..d81cc32c40f9 --- /dev/null +++ b/seed/ts-sdk/webhook-audience/src/core/logging/index.ts @@ -0,0 +1 @@ +export * from "./logger.js"; diff --git a/seed/ts-sdk/webhook-audience/src/core/logging/logger.ts b/seed/ts-sdk/webhook-audience/src/core/logging/logger.ts new file mode 100644 index 000000000000..a3f3673cda93 --- /dev/null +++ b/seed/ts-sdk/webhook-audience/src/core/logging/logger.ts @@ -0,0 +1,203 @@ +export const LogLevel = { + Debug: "debug", + Info: "info", + Warn: "warn", + Error: "error", +} as const; +export type LogLevel = (typeof LogLevel)[keyof typeof LogLevel]; +const logLevelMap: Record = { + [LogLevel.Debug]: 1, + [LogLevel.Info]: 2, + [LogLevel.Warn]: 3, + [LogLevel.Error]: 4, +}; + +export interface ILogger { + /** + * Logs a debug message. + * @param message - The message to log + * @param args - Additional arguments to log + */ + debug(message: string, ...args: unknown[]): void; + /** + * Logs an info message. + * @param message - The message to log + * @param args - Additional arguments to log + */ + info(message: string, ...args: unknown[]): void; + /** + * Logs a warning message. + * @param message - The message to log + * @param args - Additional arguments to log + */ + warn(message: string, ...args: unknown[]): void; + /** + * Logs an error message. + * @param message - The message to log + * @param args - Additional arguments to log + */ + error(message: string, ...args: unknown[]): void; +} + +/** + * Configuration for logger initialization. + */ +export interface LogConfig { + /** + * Minimum log level to output. + * @default LogLevel.Info + */ + level?: LogLevel; + /** + * Logger implementation to use. + * @default new ConsoleLogger() + */ + logger?: ILogger; + /** + * Whether logging should be silenced. + * @default true + */ + silent?: boolean; +} + +/** + * Default console-based logger implementation. + */ +export class ConsoleLogger implements ILogger { + debug(message: string, ...args: unknown[]): void { + console.debug(message, ...args); + } + info(message: string, ...args: unknown[]): void { + console.info(message, ...args); + } + warn(message: string, ...args: unknown[]): void { + console.warn(message, ...args); + } + error(message: string, ...args: unknown[]): void { + console.error(message, ...args); + } +} + +/** + * Logger class that provides level-based logging functionality. + */ +export class Logger { + private readonly level: number; + private readonly logger: ILogger; + private readonly silent: boolean; + + /** + * Creates a new logger instance. + * @param config - Logger configuration + */ + constructor(config: Required) { + this.level = logLevelMap[config.level]; + this.logger = config.logger; + this.silent = config.silent; + } + + /** + * Checks if a log level should be output based on configuration. + * @param level - The log level to check + * @returns True if the level should be logged + */ + public shouldLog(level: LogLevel): boolean { + return !this.silent && this.level <= logLevelMap[level]; + } + + /** + * Checks if debug logging is enabled. + * @returns True if debug logs should be output + */ + public isDebug(): boolean { + return this.shouldLog(LogLevel.Debug); + } + + /** + * Logs a debug message if debug logging is enabled. + * @param message - The message to log + * @param args - Additional arguments to log + */ + public debug(message: string, ...args: unknown[]): void { + if (this.isDebug()) { + this.logger.debug(message, ...args); + } + } + + /** + * Checks if info logging is enabled. + * @returns True if info logs should be output + */ + public isInfo(): boolean { + return this.shouldLog(LogLevel.Info); + } + + /** + * Logs an info message if info logging is enabled. + * @param message - The message to log + * @param args - Additional arguments to log + */ + public info(message: string, ...args: unknown[]): void { + if (this.isInfo()) { + this.logger.info(message, ...args); + } + } + + /** + * Checks if warning logging is enabled. + * @returns True if warning logs should be output + */ + public isWarn(): boolean { + return this.shouldLog(LogLevel.Warn); + } + + /** + * Logs a warning message if warning logging is enabled. + * @param message - The message to log + * @param args - Additional arguments to log + */ + public warn(message: string, ...args: unknown[]): void { + if (this.isWarn()) { + this.logger.warn(message, ...args); + } + } + + /** + * Checks if error logging is enabled. + * @returns True if error logs should be output + */ + public isError(): boolean { + return this.shouldLog(LogLevel.Error); + } + + /** + * Logs an error message if error logging is enabled. + * @param message - The message to log + * @param args - Additional arguments to log + */ + public error(message: string, ...args: unknown[]): void { + if (this.isError()) { + this.logger.error(message, ...args); + } + } +} + +export function createLogger(config?: LogConfig | Logger): Logger { + if (config == null) { + return defaultLogger; + } + if (config instanceof Logger) { + return config; + } + config = config ?? {}; + config.level ??= LogLevel.Info; + config.logger ??= new ConsoleLogger(); + config.silent ??= true; + return new Logger(config as Required); +} + +const defaultLogger: Logger = new Logger({ + level: LogLevel.Info, + logger: new ConsoleLogger(), + silent: true, +}); diff --git a/seed/ts-sdk/webhook-audience/src/core/runtime/index.ts b/seed/ts-sdk/webhook-audience/src/core/runtime/index.ts new file mode 100644 index 000000000000..cfab23f9a834 --- /dev/null +++ b/seed/ts-sdk/webhook-audience/src/core/runtime/index.ts @@ -0,0 +1 @@ +export { RUNTIME } from "./runtime.js"; diff --git a/seed/ts-sdk/webhook-audience/src/core/runtime/runtime.ts b/seed/ts-sdk/webhook-audience/src/core/runtime/runtime.ts new file mode 100644 index 000000000000..56ebbb87c4d3 --- /dev/null +++ b/seed/ts-sdk/webhook-audience/src/core/runtime/runtime.ts @@ -0,0 +1,134 @@ +interface DenoGlobal { + version: { + deno: string; + }; +} + +interface BunGlobal { + version: string; +} + +declare const Deno: DenoGlobal | undefined; +declare const Bun: BunGlobal | undefined; +declare const EdgeRuntime: string | undefined; +declare const self: typeof globalThis.self & { + importScripts?: unknown; +}; + +/** + * A constant that indicates which environment and version the SDK is running in. + */ +export const RUNTIME: Runtime = evaluateRuntime(); + +export interface Runtime { + type: "browser" | "web-worker" | "deno" | "bun" | "node" | "react-native" | "unknown" | "workerd" | "edge-runtime"; + version?: string; + parsedVersion?: number; +} + +function evaluateRuntime(): Runtime { + /** + * A constant that indicates whether the environment the code is running is a Web Browser. + */ + const isBrowser = typeof window !== "undefined" && typeof window.document !== "undefined"; + if (isBrowser) { + return { + type: "browser", + version: window.navigator.userAgent, + }; + } + + /** + * A constant that indicates whether the environment the code is running is Cloudflare. + * https://developers.cloudflare.com/workers/runtime-apis/web-standards/#navigatoruseragent + */ + const isCloudflare = typeof globalThis !== "undefined" && globalThis?.navigator?.userAgent === "Cloudflare-Workers"; + if (isCloudflare) { + return { + type: "workerd", + }; + } + + /** + * A constant that indicates whether the environment the code is running is Edge Runtime. + * https://vercel.com/docs/functions/runtimes/edge-runtime#check-if-you're-running-on-the-edge-runtime + */ + const isEdgeRuntime = typeof EdgeRuntime === "string"; + if (isEdgeRuntime) { + return { + type: "edge-runtime", + }; + } + + /** + * A constant that indicates whether the environment the code is running is a Web Worker. + */ + const isWebWorker = + typeof self === "object" && + typeof self?.importScripts === "function" && + (self.constructor?.name === "DedicatedWorkerGlobalScope" || + self.constructor?.name === "ServiceWorkerGlobalScope" || + self.constructor?.name === "SharedWorkerGlobalScope"); + if (isWebWorker) { + return { + type: "web-worker", + }; + } + + /** + * A constant that indicates whether the environment the code is running is Deno. + * FYI Deno spoofs process.versions.node, see https://deno.land/std@0.177.0/node/process.ts?s=versions + */ + const isDeno = + typeof Deno !== "undefined" && typeof Deno.version !== "undefined" && typeof Deno.version.deno !== "undefined"; + if (isDeno) { + return { + type: "deno", + version: Deno.version.deno, + }; + } + + /** + * A constant that indicates whether the environment the code is running is Bun.sh. + */ + const isBun = typeof Bun !== "undefined" && typeof Bun.version !== "undefined"; + if (isBun) { + return { + type: "bun", + version: Bun.version, + }; + } + + /** + * A constant that indicates whether the environment the code is running is in React-Native. + * This check should come before Node.js detection since React Native may have a process polyfill. + * https://github.com/facebook/react-native/blob/main/packages/react-native/Libraries/Core/setUpNavigator.js + */ + const isReactNative = typeof navigator !== "undefined" && navigator?.product === "ReactNative"; + if (isReactNative) { + return { + type: "react-native", + }; + } + + /** + * A constant that indicates whether the environment the code is running is Node.JS. + */ + const isNode = + typeof process !== "undefined" && + "version" in process && + !!process.version && + "versions" in process && + !!process.versions?.node; + if (isNode) { + return { + type: "node", + version: process.versions.node, + parsedVersion: Number(process.versions.node.split(".")[0]), + }; + } + + return { + type: "unknown", + }; +} diff --git a/seed/ts-sdk/webhook-audience/src/core/url/encodePathParam.ts b/seed/ts-sdk/webhook-audience/src/core/url/encodePathParam.ts new file mode 100644 index 000000000000..19b901244218 --- /dev/null +++ b/seed/ts-sdk/webhook-audience/src/core/url/encodePathParam.ts @@ -0,0 +1,18 @@ +export function encodePathParam(param: unknown): string { + if (param === null) { + return "null"; + } + const typeofParam = typeof param; + switch (typeofParam) { + case "undefined": + return "undefined"; + case "string": + case "number": + case "boolean": + break; + default: + param = String(param); + break; + } + return encodeURIComponent(param as string | number | boolean); +} diff --git a/seed/ts-sdk/webhook-audience/src/core/url/index.ts b/seed/ts-sdk/webhook-audience/src/core/url/index.ts new file mode 100644 index 000000000000..f2e0fa2d2221 --- /dev/null +++ b/seed/ts-sdk/webhook-audience/src/core/url/index.ts @@ -0,0 +1,3 @@ +export { encodePathParam } from "./encodePathParam.js"; +export { join } from "./join.js"; +export { toQueryString } from "./qs.js"; diff --git a/seed/ts-sdk/webhook-audience/src/core/url/join.ts b/seed/ts-sdk/webhook-audience/src/core/url/join.ts new file mode 100644 index 000000000000..7ca7daef094d --- /dev/null +++ b/seed/ts-sdk/webhook-audience/src/core/url/join.ts @@ -0,0 +1,79 @@ +export function join(base: string, ...segments: string[]): string { + if (!base) { + return ""; + } + + if (segments.length === 0) { + return base; + } + + if (base.includes("://")) { + let url: URL; + try { + url = new URL(base); + } catch { + return joinPath(base, ...segments); + } + + const lastSegment = segments[segments.length - 1]; + const shouldPreserveTrailingSlash = lastSegment?.endsWith("/"); + + for (const segment of segments) { + const cleanSegment = trimSlashes(segment); + if (cleanSegment) { + url.pathname = joinPathSegments(url.pathname, cleanSegment); + } + } + + if (shouldPreserveTrailingSlash && !url.pathname.endsWith("/")) { + url.pathname += "/"; + } + + return url.toString(); + } + + return joinPath(base, ...segments); +} + +function joinPath(base: string, ...segments: string[]): string { + if (segments.length === 0) { + return base; + } + + let result = base; + + const lastSegment = segments[segments.length - 1]; + const shouldPreserveTrailingSlash = lastSegment?.endsWith("/"); + + for (const segment of segments) { + const cleanSegment = trimSlashes(segment); + if (cleanSegment) { + result = joinPathSegments(result, cleanSegment); + } + } + + if (shouldPreserveTrailingSlash && !result.endsWith("/")) { + result += "/"; + } + + return result; +} + +function joinPathSegments(left: string, right: string): string { + if (left.endsWith("/")) { + return left + right; + } + return `${left}/${right}`; +} + +function trimSlashes(str: string): string { + if (!str) return str; + + let start = 0; + let end = str.length; + + if (str.startsWith("/")) start = 1; + if (str.endsWith("/")) end = str.length - 1; + + return start === 0 && end === str.length ? str : str.slice(start, end); +} diff --git a/seed/ts-sdk/webhook-audience/src/core/url/qs.ts b/seed/ts-sdk/webhook-audience/src/core/url/qs.ts new file mode 100644 index 000000000000..13e89be9d9a6 --- /dev/null +++ b/seed/ts-sdk/webhook-audience/src/core/url/qs.ts @@ -0,0 +1,74 @@ +interface QueryStringOptions { + arrayFormat?: "indices" | "repeat"; + encode?: boolean; +} + +const defaultQsOptions: Required = { + arrayFormat: "indices", + encode: true, +} as const; + +function encodeValue(value: unknown, shouldEncode: boolean): string { + if (value === undefined) { + return ""; + } + if (value === null) { + return ""; + } + const stringValue = String(value); + return shouldEncode ? encodeURIComponent(stringValue) : stringValue; +} + +function stringifyObject(obj: Record, prefix = "", options: Required): string[] { + const parts: string[] = []; + + for (const [key, value] of Object.entries(obj)) { + const fullKey = prefix ? `${prefix}[${key}]` : key; + + if (value === undefined) { + continue; + } + + if (Array.isArray(value)) { + if (value.length === 0) { + continue; + } + for (let i = 0; i < value.length; i++) { + const item = value[i]; + if (item === undefined) { + continue; + } + if (typeof item === "object" && !Array.isArray(item) && item !== null) { + const arrayKey = options.arrayFormat === "indices" ? `${fullKey}[${i}]` : fullKey; + parts.push(...stringifyObject(item as Record, arrayKey, options)); + } else { + const arrayKey = options.arrayFormat === "indices" ? `${fullKey}[${i}]` : fullKey; + const encodedKey = options.encode ? encodeURIComponent(arrayKey) : arrayKey; + parts.push(`${encodedKey}=${encodeValue(item, options.encode)}`); + } + } + } else if (typeof value === "object" && value !== null) { + if (Object.keys(value as Record).length === 0) { + continue; + } + parts.push(...stringifyObject(value as Record, fullKey, options)); + } else { + const encodedKey = options.encode ? encodeURIComponent(fullKey) : fullKey; + parts.push(`${encodedKey}=${encodeValue(value, options.encode)}`); + } + } + + return parts; +} + +export function toQueryString(obj: unknown, options?: QueryStringOptions): string { + if (obj == null || typeof obj !== "object") { + return ""; + } + + const parts = stringifyObject(obj as Record, "", { + ...defaultQsOptions, + ...options, + }); + return parts.join("&"); +} diff --git a/seed/ts-sdk/webhook-audience/src/errors/SeedApiError.ts b/seed/ts-sdk/webhook-audience/src/errors/SeedApiError.ts new file mode 100644 index 000000000000..feb7d3461003 --- /dev/null +++ b/seed/ts-sdk/webhook-audience/src/errors/SeedApiError.ts @@ -0,0 +1,58 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as core from "../core/index.js"; +import { toJson } from "../core/json.js"; + +export class SeedApiError extends Error { + public readonly statusCode?: number; + public readonly body?: unknown; + public readonly rawResponse?: core.RawResponse; + + constructor({ + message, + statusCode, + body, + rawResponse, + }: { + message?: string; + statusCode?: number; + body?: unknown; + rawResponse?: core.RawResponse; + }) { + super(buildMessage({ message, statusCode, body })); + Object.setPrototypeOf(this, new.target.prototype); + if (Error.captureStackTrace) { + Error.captureStackTrace(this, this.constructor); + } + + this.name = this.constructor.name; + this.statusCode = statusCode; + this.body = body; + this.rawResponse = rawResponse; + } +} + +function buildMessage({ + message, + statusCode, + body, +}: { + message: string | undefined; + statusCode: number | undefined; + body: unknown | undefined; +}): string { + const lines: string[] = []; + if (message != null) { + lines.push(message); + } + + if (statusCode != null) { + lines.push(`Status code: ${statusCode.toString()}`); + } + + if (body != null) { + lines.push(`Body: ${toJson(body, undefined, 2)}`); + } + + return lines.join("\n"); +} diff --git a/seed/ts-sdk/webhook-audience/src/errors/SeedApiTimeoutError.ts b/seed/ts-sdk/webhook-audience/src/errors/SeedApiTimeoutError.ts new file mode 100644 index 000000000000..5f451edf37bb --- /dev/null +++ b/seed/ts-sdk/webhook-audience/src/errors/SeedApiTimeoutError.ts @@ -0,0 +1,13 @@ +// This file was auto-generated by Fern from our API Definition. + +export class SeedApiTimeoutError extends Error { + constructor(message: string) { + super(message); + Object.setPrototypeOf(this, new.target.prototype); + if (Error.captureStackTrace) { + Error.captureStackTrace(this, this.constructor); + } + + this.name = this.constructor.name; + } +} diff --git a/seed/ts-sdk/webhook-audience/src/errors/handleNonStatusCodeError.ts b/seed/ts-sdk/webhook-audience/src/errors/handleNonStatusCodeError.ts new file mode 100644 index 000000000000..fdb7a48879da --- /dev/null +++ b/seed/ts-sdk/webhook-audience/src/errors/handleNonStatusCodeError.ts @@ -0,0 +1,37 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as core from "../core/index.js"; +import * as errors from "./index.js"; + +export function handleNonStatusCodeError( + error: core.Fetcher.Error, + rawResponse: core.RawResponse, + method: string, + path: string, +): never { + switch (error.reason) { + case "non-json": + throw new errors.SeedApiError({ + statusCode: error.statusCode, + body: error.rawBody, + rawResponse: rawResponse, + }); + case "body-is-null": + throw new errors.SeedApiError({ + statusCode: error.statusCode, + rawResponse: rawResponse, + }); + case "timeout": + throw new errors.SeedApiTimeoutError(`Timeout exceeded when calling ${method} ${path}.`); + case "unknown": + throw new errors.SeedApiError({ + message: error.errorMessage, + rawResponse: rawResponse, + }); + default: + throw new errors.SeedApiError({ + message: "Unknown error", + rawResponse: rawResponse, + }); + } +} diff --git a/seed/ts-sdk/webhook-audience/src/errors/index.ts b/seed/ts-sdk/webhook-audience/src/errors/index.ts new file mode 100644 index 000000000000..09e82b954c26 --- /dev/null +++ b/seed/ts-sdk/webhook-audience/src/errors/index.ts @@ -0,0 +1,2 @@ +export { SeedApiError } from "./SeedApiError.js"; +export { SeedApiTimeoutError } from "./SeedApiTimeoutError.js"; diff --git a/seed/ts-sdk/webhook-audience/src/exports.ts b/seed/ts-sdk/webhook-audience/src/exports.ts new file mode 100644 index 000000000000..7b70ee14fc02 --- /dev/null +++ b/seed/ts-sdk/webhook-audience/src/exports.ts @@ -0,0 +1 @@ +export * from "./core/exports.js"; diff --git a/seed/ts-sdk/webhook-audience/src/index.ts b/seed/ts-sdk/webhook-audience/src/index.ts new file mode 100644 index 000000000000..2ec8b2e1083d --- /dev/null +++ b/seed/ts-sdk/webhook-audience/src/index.ts @@ -0,0 +1,4 @@ +export * as SeedApi from "./api/index.js"; +export type { BaseClientOptions, BaseRequestOptions } from "./BaseClient.js"; +export { SeedApiError, SeedApiTimeoutError } from "./errors/index.js"; +export * from "./exports.js"; diff --git a/seed/ts-sdk/webhook-audience/src/version.ts b/seed/ts-sdk/webhook-audience/src/version.ts new file mode 100644 index 000000000000..b643a3e3ea27 --- /dev/null +++ b/seed/ts-sdk/webhook-audience/src/version.ts @@ -0,0 +1 @@ +export const SDK_VERSION = "0.0.1"; diff --git a/seed/ts-sdk/webhook-audience/tests/custom.test.ts b/seed/ts-sdk/webhook-audience/tests/custom.test.ts new file mode 100644 index 000000000000..7f5e031c8396 --- /dev/null +++ b/seed/ts-sdk/webhook-audience/tests/custom.test.ts @@ -0,0 +1,13 @@ +/** + * This is a custom test file, if you wish to add more tests + * to your SDK. + * Be sure to mark this file in `.fernignore`. + * + * If you include example requests/responses in your fern definition, + * you will have tests automatically generated for you. + */ +describe("test", () => { + it("default", () => { + expect(true).toBe(true); + }); +}); diff --git a/seed/ts-sdk/webhook-audience/tests/mock-server/MockServer.ts b/seed/ts-sdk/webhook-audience/tests/mock-server/MockServer.ts new file mode 100644 index 000000000000..954872157d52 --- /dev/null +++ b/seed/ts-sdk/webhook-audience/tests/mock-server/MockServer.ts @@ -0,0 +1,29 @@ +import type { RequestHandlerOptions } from "msw"; +import type { SetupServer } from "msw/node"; + +import { mockEndpointBuilder } from "./mockEndpointBuilder"; + +export interface MockServerOptions { + baseUrl: string; + server: SetupServer; +} + +export class MockServer { + private readonly server: SetupServer; + public readonly baseUrl: string; + + constructor({ baseUrl, server }: MockServerOptions) { + this.baseUrl = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl; + this.server = server; + } + + public mockEndpoint(options?: RequestHandlerOptions): ReturnType { + const builder = mockEndpointBuilder({ + once: options?.once ?? true, + onBuild: (handler) => { + this.server.use(handler); + }, + }).baseUrl(this.baseUrl); + return builder; + } +} diff --git a/seed/ts-sdk/webhook-audience/tests/mock-server/MockServerPool.ts b/seed/ts-sdk/webhook-audience/tests/mock-server/MockServerPool.ts new file mode 100644 index 000000000000..e1a90f7fb2e3 --- /dev/null +++ b/seed/ts-sdk/webhook-audience/tests/mock-server/MockServerPool.ts @@ -0,0 +1,106 @@ +import { setupServer } from "msw/node"; + +import { fromJson, toJson } from "../../src/core/json"; +import { MockServer } from "./MockServer"; +import { randomBaseUrl } from "./randomBaseUrl"; + +const mswServer = setupServer(); +interface MockServerOptions { + baseUrl?: string; +} + +async function formatHttpRequest(request: Request, id?: string): Promise { + try { + const clone = request.clone(); + const headers = [...clone.headers.entries()].map(([k, v]) => `${k}: ${v}`).join("\n"); + + let body = ""; + try { + const contentType = clone.headers.get("content-type"); + if (contentType?.includes("application/json")) { + body = toJson(fromJson(await clone.text()), undefined, 2); + } else if (clone.body) { + body = await clone.text(); + } + } catch (_e) { + body = "(unable to parse body)"; + } + + const title = id ? `### Request ${id} ###\n` : ""; + const firstLine = `${title}${request.method} ${request.url.toString()} HTTP/1.1`; + + return `\n${firstLine}\n${headers}\n\n${body || "(no body)"}\n`; + } catch (e) { + return `Error formatting request: ${e}`; + } +} + +async function formatHttpResponse(response: Response, id?: string): Promise { + try { + const clone = response.clone(); + const headers = [...clone.headers.entries()].map(([k, v]) => `${k}: ${v}`).join("\n"); + + let body = ""; + try { + const contentType = clone.headers.get("content-type"); + if (contentType?.includes("application/json")) { + body = toJson(fromJson(await clone.text()), undefined, 2); + } else if (clone.body) { + body = await clone.text(); + } + } catch (_e) { + body = "(unable to parse body)"; + } + + const title = id ? `### Response for ${id} ###\n` : ""; + const firstLine = `${title}HTTP/1.1 ${response.status} ${response.statusText}`; + + return `\n${firstLine}\n${headers}\n\n${body || "(no body)"}\n`; + } catch (e) { + return `Error formatting response: ${e}`; + } +} + +class MockServerPool { + private servers: MockServer[] = []; + + public createServer(options?: Partial): MockServer { + const baseUrl = options?.baseUrl || randomBaseUrl(); + const server = new MockServer({ baseUrl, server: mswServer }); + this.servers.push(server); + return server; + } + + public getServers(): MockServer[] { + return [...this.servers]; + } + + public listen(): void { + const onUnhandledRequest = process.env.LOG_LEVEL === "debug" ? "warn" : "bypass"; + mswServer.listen({ onUnhandledRequest }); + + if (process.env.LOG_LEVEL === "debug") { + mswServer.events.on("request:start", async ({ request, requestId }) => { + const formattedRequest = await formatHttpRequest(request, requestId); + console.debug(`request:start\n${formattedRequest}`); + }); + + mswServer.events.on("request:unhandled", async ({ request, requestId }) => { + const formattedRequest = await formatHttpRequest(request, requestId); + console.debug(`request:unhandled\n${formattedRequest}`); + }); + + mswServer.events.on("response:mocked", async ({ request, response, requestId }) => { + const formattedResponse = await formatHttpResponse(response, requestId); + console.debug(`response:mocked\n${formattedResponse}`); + }); + } + } + + public close(): void { + this.servers = []; + mswServer.close(); + } +} + +export const mockServerPool = new MockServerPool(); diff --git a/seed/ts-sdk/webhook-audience/tests/mock-server/mockEndpointBuilder.ts b/seed/ts-sdk/webhook-audience/tests/mock-server/mockEndpointBuilder.ts new file mode 100644 index 000000000000..78985e7211b4 --- /dev/null +++ b/seed/ts-sdk/webhook-audience/tests/mock-server/mockEndpointBuilder.ts @@ -0,0 +1,227 @@ +import { type DefaultBodyType, type HttpHandler, HttpResponse, type HttpResponseResolver, http } from "msw"; + +import { url } from "../../src/core"; +import { toJson } from "../../src/core/json"; +import { withFormUrlEncoded } from "./withFormUrlEncoded"; +import { withHeaders } from "./withHeaders"; +import { type WithJsonOptions, withJson } from "./withJson"; + +type HttpMethod = "all" | "get" | "post" | "put" | "delete" | "patch" | "options" | "head"; + +interface MethodStage { + baseUrl(baseUrl: string): MethodStage; + all(path: string): RequestHeadersStage; + get(path: string): RequestHeadersStage; + post(path: string): RequestHeadersStage; + put(path: string): RequestHeadersStage; + delete(path: string): RequestHeadersStage; + patch(path: string): RequestHeadersStage; + options(path: string): RequestHeadersStage; + head(path: string): RequestHeadersStage; +} + +interface RequestHeadersStage extends RequestBodyStage, ResponseStage { + header(name: string, value: string): RequestHeadersStage; + headers(headers: Record): RequestBodyStage; +} + +interface RequestBodyStage extends ResponseStage { + jsonBody(body: unknown, options?: WithJsonOptions): ResponseStage; + formUrlEncodedBody(body: unknown): ResponseStage; +} + +interface ResponseStage { + respondWith(): ResponseStatusStage; +} +interface ResponseStatusStage { + statusCode(statusCode: number): ResponseHeaderStage; +} + +interface ResponseHeaderStage extends ResponseBodyStage, BuildStage { + header(name: string, value: string): ResponseHeaderStage; + headers(headers: Record): ResponseHeaderStage; +} + +interface ResponseBodyStage { + jsonBody(body: unknown): BuildStage; +} + +interface BuildStage { + build(): HttpHandler; +} + +export interface HttpHandlerBuilderOptions { + onBuild?: (handler: HttpHandler) => void; + once?: boolean; +} + +class RequestBuilder implements MethodStage, RequestHeadersStage, RequestBodyStage, ResponseStage { + private method: HttpMethod = "get"; + private _baseUrl: string = ""; + private path: string = "/"; + private readonly predicates: ((resolver: HttpResponseResolver) => HttpResponseResolver)[] = []; + private readonly handlerOptions?: HttpHandlerBuilderOptions; + + constructor(options?: HttpHandlerBuilderOptions) { + this.handlerOptions = options; + } + + baseUrl(baseUrl: string): MethodStage { + this._baseUrl = baseUrl; + return this; + } + + all(path: string): RequestHeadersStage { + this.method = "all"; + this.path = path; + return this; + } + + get(path: string): RequestHeadersStage { + this.method = "get"; + this.path = path; + return this; + } + + post(path: string): RequestHeadersStage { + this.method = "post"; + this.path = path; + return this; + } + + put(path: string): RequestHeadersStage { + this.method = "put"; + this.path = path; + return this; + } + + delete(path: string): RequestHeadersStage { + this.method = "delete"; + this.path = path; + return this; + } + + patch(path: string): RequestHeadersStage { + this.method = "patch"; + this.path = path; + return this; + } + + options(path: string): RequestHeadersStage { + this.method = "options"; + this.path = path; + return this; + } + + head(path: string): RequestHeadersStage { + this.method = "head"; + this.path = path; + return this; + } + + header(name: string, value: string): RequestHeadersStage { + this.predicates.push((resolver) => withHeaders({ [name]: value }, resolver)); + return this; + } + + headers(headers: Record): RequestBodyStage { + this.predicates.push((resolver) => withHeaders(headers, resolver)); + return this; + } + + jsonBody(body: unknown, options?: WithJsonOptions): ResponseStage { + if (body === undefined) { + throw new Error("Undefined is not valid JSON. Do not call jsonBody if you want an empty body."); + } + this.predicates.push((resolver) => withJson(body, resolver, options)); + return this; + } + + formUrlEncodedBody(body: unknown): ResponseStage { + if (body === undefined) { + throw new Error( + "Undefined is not valid for form-urlencoded. Do not call formUrlEncodedBody if you want an empty body.", + ); + } + this.predicates.push((resolver) => withFormUrlEncoded(body, resolver)); + return this; + } + + respondWith(): ResponseStatusStage { + return new ResponseBuilder(this.method, this.buildUrl(), this.predicates, this.handlerOptions); + } + + private buildUrl(): string { + return url.join(this._baseUrl, this.path); + } +} + +class ResponseBuilder implements ResponseStatusStage, ResponseHeaderStage, ResponseBodyStage, BuildStage { + private readonly method: HttpMethod; + private readonly url: string; + private readonly requestPredicates: ((resolver: HttpResponseResolver) => HttpResponseResolver)[]; + private readonly handlerOptions?: HttpHandlerBuilderOptions; + + private responseStatusCode: number = 200; + private responseHeaders: Record = {}; + private responseBody: DefaultBodyType = undefined; + + constructor( + method: HttpMethod, + url: string, + requestPredicates: ((resolver: HttpResponseResolver) => HttpResponseResolver)[], + options?: HttpHandlerBuilderOptions, + ) { + this.method = method; + this.url = url; + this.requestPredicates = requestPredicates; + this.handlerOptions = options; + } + + public statusCode(code: number): ResponseHeaderStage { + this.responseStatusCode = code; + return this; + } + + public header(name: string, value: string): ResponseHeaderStage { + this.responseHeaders[name] = value; + return this; + } + + public headers(headers: Record): ResponseHeaderStage { + this.responseHeaders = { ...this.responseHeaders, ...headers }; + return this; + } + + public jsonBody(body: unknown): BuildStage { + if (body === undefined) { + throw new Error("Undefined is not valid JSON. Do not call jsonBody if you expect an empty body."); + } + this.responseBody = toJson(body); + return this; + } + + public build(): HttpHandler { + const responseResolver: HttpResponseResolver = () => { + const response = new HttpResponse(this.responseBody, { + status: this.responseStatusCode, + headers: this.responseHeaders, + }); + // if no Content-Type header is set, delete the default text content type that is set + if (Object.keys(this.responseHeaders).some((key) => key.toLowerCase() === "content-type") === false) { + response.headers.delete("Content-Type"); + } + return response; + }; + + const finalResolver = this.requestPredicates.reduceRight((acc, predicate) => predicate(acc), responseResolver); + + const handler = http[this.method](this.url, finalResolver, this.handlerOptions); + this.handlerOptions?.onBuild?.(handler); + return handler; + } +} + +export function mockEndpointBuilder(options?: HttpHandlerBuilderOptions): MethodStage { + return new RequestBuilder(options); +} diff --git a/seed/ts-sdk/webhook-audience/tests/mock-server/randomBaseUrl.ts b/seed/ts-sdk/webhook-audience/tests/mock-server/randomBaseUrl.ts new file mode 100644 index 000000000000..031aa6408aca --- /dev/null +++ b/seed/ts-sdk/webhook-audience/tests/mock-server/randomBaseUrl.ts @@ -0,0 +1,4 @@ +export function randomBaseUrl(): string { + const randomString = Math.random().toString(36).substring(2, 15); + return `http://${randomString}.localhost`; +} diff --git a/seed/ts-sdk/webhook-audience/tests/mock-server/setup.ts b/seed/ts-sdk/webhook-audience/tests/mock-server/setup.ts new file mode 100644 index 000000000000..aeb3a95af7dc --- /dev/null +++ b/seed/ts-sdk/webhook-audience/tests/mock-server/setup.ts @@ -0,0 +1,10 @@ +import { afterAll, beforeAll } from "vitest"; + +import { mockServerPool } from "./MockServerPool"; + +beforeAll(() => { + mockServerPool.listen(); +}); +afterAll(() => { + mockServerPool.close(); +}); diff --git a/seed/ts-sdk/webhook-audience/tests/mock-server/withFormUrlEncoded.ts b/seed/ts-sdk/webhook-audience/tests/mock-server/withFormUrlEncoded.ts new file mode 100644 index 000000000000..e250cb3c0f61 --- /dev/null +++ b/seed/ts-sdk/webhook-audience/tests/mock-server/withFormUrlEncoded.ts @@ -0,0 +1,89 @@ +import { type HttpResponseResolver, passthrough } from "msw"; + +import { toJson } from "../../src/core/json"; + +/** + * Creates a request matcher that validates if the request form-urlencoded body exactly matches the expected object + * @param expectedBody - The exact body object to match against + * @param resolver - Response resolver to execute if body matches + */ +export function withFormUrlEncoded(expectedBody: unknown, resolver: HttpResponseResolver): HttpResponseResolver { + return async (args) => { + const { request } = args; + + let clonedRequest: Request; + let bodyText: string | undefined; + let actualBody: Record; + try { + clonedRequest = request.clone(); + bodyText = await clonedRequest.text(); + if (bodyText === "") { + // Empty body is valid if expected body is also empty + const isExpectedEmpty = + expectedBody != null && + typeof expectedBody === "object" && + Object.keys(expectedBody as Record).length === 0; + if (!isExpectedEmpty) { + console.error("Request body is empty, expected a form-urlencoded body."); + return passthrough(); + } + actualBody = {}; + } else { + const params = new URLSearchParams(bodyText); + actualBody = {}; + for (const [key, value] of params.entries()) { + actualBody[key] = value; + } + } + } catch (error) { + console.error(`Error processing form-urlencoded request body:\n\tError: ${error}\n\tBody: ${bodyText}`); + return passthrough(); + } + + const mismatches = findMismatches(actualBody, expectedBody); + if (Object.keys(mismatches).length > 0) { + console.error("Form-urlencoded body mismatch:", toJson(mismatches, undefined, 2)); + return passthrough(); + } + + return resolver(args); + }; +} + +function findMismatches(actual: any, expected: any): Record { + const mismatches: Record = {}; + + if (typeof actual !== typeof expected) { + return { value: { actual, expected } }; + } + + if (typeof actual !== "object" || actual === null || expected === null) { + if (actual !== expected) { + return { value: { actual, expected } }; + } + return {}; + } + + const actualKeys = Object.keys(actual); + const expectedKeys = Object.keys(expected); + + const allKeys = new Set([...actualKeys, ...expectedKeys]); + + for (const key of allKeys) { + if (!expectedKeys.includes(key)) { + if (actual[key] === undefined) { + continue; + } + mismatches[key] = { actual: actual[key], expected: undefined }; + } else if (!actualKeys.includes(key)) { + if (expected[key] === undefined) { + continue; + } + mismatches[key] = { actual: undefined, expected: expected[key] }; + } else if (actual[key] !== expected[key]) { + mismatches[key] = { actual: actual[key], expected: expected[key] }; + } + } + + return mismatches; +} diff --git a/seed/ts-sdk/webhook-audience/tests/mock-server/withHeaders.ts b/seed/ts-sdk/webhook-audience/tests/mock-server/withHeaders.ts new file mode 100644 index 000000000000..6599d2b4a92d --- /dev/null +++ b/seed/ts-sdk/webhook-audience/tests/mock-server/withHeaders.ts @@ -0,0 +1,70 @@ +import { type HttpResponseResolver, passthrough } from "msw"; + +/** + * Creates a request matcher that validates if request headers match specified criteria + * @param expectedHeaders - Headers to match against + * @param resolver - Response resolver to execute if headers match + */ +export function withHeaders( + expectedHeaders: Record boolean)>, + resolver: HttpResponseResolver, +): HttpResponseResolver { + return (args) => { + const { request } = args; + const { headers } = request; + + const mismatches: Record< + string, + { actual: string | null; expected: string | RegExp | ((value: string) => boolean) } + > = {}; + + for (const [key, expectedValue] of Object.entries(expectedHeaders)) { + const actualValue = headers.get(key); + + if (actualValue === null) { + mismatches[key] = { actual: null, expected: expectedValue }; + continue; + } + + if (typeof expectedValue === "function") { + if (!expectedValue(actualValue)) { + mismatches[key] = { actual: actualValue, expected: expectedValue }; + } + } else if (expectedValue instanceof RegExp) { + if (!expectedValue.test(actualValue)) { + mismatches[key] = { actual: actualValue, expected: expectedValue }; + } + } else if (expectedValue !== actualValue) { + mismatches[key] = { actual: actualValue, expected: expectedValue }; + } + } + + if (Object.keys(mismatches).length > 0) { + const formattedMismatches = formatHeaderMismatches(mismatches); + console.error("Header mismatch:", formattedMismatches); + return passthrough(); + } + + return resolver(args); + }; +} + +function formatHeaderMismatches( + mismatches: Record boolean) }>, +): Record { + const formatted: Record = {}; + + for (const [key, { actual, expected }] of Object.entries(mismatches)) { + formatted[key] = { + actual, + expected: + expected instanceof RegExp + ? expected.toString() + : typeof expected === "function" + ? "[Function]" + : expected, + }; + } + + return formatted; +} diff --git a/seed/ts-sdk/webhook-audience/tests/mock-server/withJson.ts b/seed/ts-sdk/webhook-audience/tests/mock-server/withJson.ts new file mode 100644 index 000000000000..3e8800a0c374 --- /dev/null +++ b/seed/ts-sdk/webhook-audience/tests/mock-server/withJson.ts @@ -0,0 +1,173 @@ +import { type HttpResponseResolver, passthrough } from "msw"; + +import { fromJson, toJson } from "../../src/core/json"; + +export interface WithJsonOptions { + /** + * List of field names to ignore when comparing request bodies. + * This is useful for pagination cursor fields that change between requests. + */ + ignoredFields?: string[]; +} + +/** + * Creates a request matcher that validates if the request JSON body exactly matches the expected object + * @param expectedBody - The exact body object to match against + * @param resolver - Response resolver to execute if body matches + * @param options - Optional configuration including fields to ignore + */ +export function withJson( + expectedBody: unknown, + resolver: HttpResponseResolver, + options?: WithJsonOptions, +): HttpResponseResolver { + const ignoredFields = options?.ignoredFields ?? []; + return async (args) => { + const { request } = args; + + let clonedRequest: Request; + let bodyText: string | undefined; + let actualBody: unknown; + try { + clonedRequest = request.clone(); + bodyText = await clonedRequest.text(); + if (bodyText === "") { + console.error("Request body is empty, expected a JSON object."); + return passthrough(); + } + actualBody = fromJson(bodyText); + } catch (error) { + console.error(`Error processing request body:\n\tError: ${error}\n\tBody: ${bodyText}`); + return passthrough(); + } + + const mismatches = findMismatches(actualBody, expectedBody); + const filteredMismatches = Object.keys(mismatches).filter((key) => !ignoredFields.includes(key)); + if (filteredMismatches.length > 0) { + console.error("JSON body mismatch:", toJson(mismatches, undefined, 2)); + return passthrough(); + } + + return resolver(args); + }; +} + +function findMismatches(actual: any, expected: any): Record { + const mismatches: Record = {}; + + if (typeof actual !== typeof expected) { + if (areEquivalent(actual, expected)) { + return {}; + } + return { value: { actual, expected } }; + } + + if (typeof actual !== "object" || actual === null || expected === null) { + if (actual !== expected) { + if (areEquivalent(actual, expected)) { + return {}; + } + return { value: { actual, expected } }; + } + return {}; + } + + if (Array.isArray(actual) && Array.isArray(expected)) { + if (actual.length !== expected.length) { + return { length: { actual: actual.length, expected: expected.length } }; + } + + const arrayMismatches: Record = {}; + for (let i = 0; i < actual.length; i++) { + const itemMismatches = findMismatches(actual[i], expected[i]); + if (Object.keys(itemMismatches).length > 0) { + for (const [mismatchKey, mismatchValue] of Object.entries(itemMismatches)) { + arrayMismatches[`[${i}]${mismatchKey === "value" ? "" : `.${mismatchKey}`}`] = mismatchValue; + } + } + } + return arrayMismatches; + } + + const actualKeys = Object.keys(actual); + const expectedKeys = Object.keys(expected); + + const allKeys = new Set([...actualKeys, ...expectedKeys]); + + for (const key of allKeys) { + if (!expectedKeys.includes(key)) { + if (actual[key] === undefined) { + continue; // Skip undefined values in actual + } + mismatches[key] = { actual: actual[key], expected: undefined }; + } else if (!actualKeys.includes(key)) { + if (expected[key] === undefined) { + continue; // Skip undefined values in expected + } + mismatches[key] = { actual: undefined, expected: expected[key] }; + } else if ( + typeof actual[key] === "object" && + actual[key] !== null && + typeof expected[key] === "object" && + expected[key] !== null + ) { + const nestedMismatches = findMismatches(actual[key], expected[key]); + if (Object.keys(nestedMismatches).length > 0) { + for (const [nestedKey, nestedValue] of Object.entries(nestedMismatches)) { + mismatches[`${key}${nestedKey === "value" ? "" : `.${nestedKey}`}`] = nestedValue; + } + } + } else if (actual[key] !== expected[key]) { + if (areEquivalent(actual[key], expected[key])) { + continue; + } + mismatches[key] = { actual: actual[key], expected: expected[key] }; + } + } + + return mismatches; +} + +function areEquivalent(actual: unknown, expected: unknown): boolean { + if (actual === expected) { + return true; + } + if (isEquivalentBigInt(actual, expected)) { + return true; + } + if (isEquivalentDatetime(actual, expected)) { + return true; + } + return false; +} + +function isEquivalentBigInt(actual: unknown, expected: unknown) { + if (typeof actual === "number") { + actual = BigInt(actual); + } + if (typeof expected === "number") { + expected = BigInt(expected); + } + if (typeof actual === "bigint" && typeof expected === "bigint") { + return actual === expected; + } + return false; +} + +function isEquivalentDatetime(str1: unknown, str2: unknown): boolean { + if (typeof str1 !== "string" || typeof str2 !== "string") { + return false; + } + const isoDatePattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/; + if (!isoDatePattern.test(str1) || !isoDatePattern.test(str2)) { + return false; + } + + try { + const date1 = new Date(str1).getTime(); + const date2 = new Date(str2).getTime(); + return date1 === date2; + } catch { + return false; + } +} diff --git a/seed/ts-sdk/webhook-audience/tests/setup.ts b/seed/ts-sdk/webhook-audience/tests/setup.ts new file mode 100644 index 000000000000..a5651f81ba10 --- /dev/null +++ b/seed/ts-sdk/webhook-audience/tests/setup.ts @@ -0,0 +1,80 @@ +import { expect } from "vitest"; + +interface CustomMatchers { + toContainHeaders(expectedHeaders: Record): R; +} + +declare module "vitest" { + interface Assertion extends CustomMatchers {} + interface AsymmetricMatchersContaining extends CustomMatchers {} +} + +expect.extend({ + toContainHeaders(actual: unknown, expectedHeaders: Record) { + const isHeaders = actual instanceof Headers; + const isPlainObject = typeof actual === "object" && actual !== null && !Array.isArray(actual); + + if (!isHeaders && !isPlainObject) { + throw new TypeError("Received value must be an instance of Headers or a plain object!"); + } + + if (typeof expectedHeaders !== "object" || expectedHeaders === null || Array.isArray(expectedHeaders)) { + throw new TypeError("Expected headers must be a plain object!"); + } + + const missingHeaders: string[] = []; + const mismatchedHeaders: Array<{ key: string; expected: string; actual: string | null }> = []; + + for (const [key, value] of Object.entries(expectedHeaders)) { + let actualValue: string | null = null; + + if (isHeaders) { + // Headers.get() is already case-insensitive + actualValue = (actual as Headers).get(key); + } else { + // For plain objects, do case-insensitive lookup + const actualObj = actual as Record; + const lowerKey = key.toLowerCase(); + const foundKey = Object.keys(actualObj).find((k) => k.toLowerCase() === lowerKey); + actualValue = foundKey ? actualObj[foundKey] : null; + } + + if (actualValue === null || actualValue === undefined) { + missingHeaders.push(key); + } else if (actualValue !== value) { + mismatchedHeaders.push({ key, expected: value, actual: actualValue }); + } + } + + const pass = missingHeaders.length === 0 && mismatchedHeaders.length === 0; + + const actualType = isHeaders ? "Headers" : "object"; + + if (pass) { + return { + message: () => `expected ${actualType} not to contain ${this.utils.printExpected(expectedHeaders)}`, + pass: true, + }; + } else { + const messages: string[] = []; + + if (missingHeaders.length > 0) { + messages.push(`Missing headers: ${this.utils.printExpected(missingHeaders.join(", "))}`); + } + + if (mismatchedHeaders.length > 0) { + const mismatches = mismatchedHeaders.map( + ({ key, expected, actual }) => + `${key}: expected ${this.utils.printExpected(expected)} but got ${this.utils.printReceived(actual)}`, + ); + messages.push(mismatches.join("\n")); + } + + return { + message: () => + `expected ${actualType} to contain ${this.utils.printExpected(expectedHeaders)}\n\n${messages.join("\n")}`, + pass: false, + }; + } + }, +}); diff --git a/seed/ts-sdk/webhook-audience/tests/tsconfig.json b/seed/ts-sdk/webhook-audience/tests/tsconfig.json new file mode 100644 index 000000000000..a477df47920c --- /dev/null +++ b/seed/ts-sdk/webhook-audience/tests/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "outDir": null, + "rootDir": "..", + "baseUrl": "..", + "types": ["vitest/globals"] + }, + "include": ["../src", "../tests"], + "exclude": [] +} diff --git a/seed/ts-sdk/webhook-audience/tests/unit/fetcher/Fetcher.test.ts b/seed/ts-sdk/webhook-audience/tests/unit/fetcher/Fetcher.test.ts new file mode 100644 index 000000000000..6c17624228bb --- /dev/null +++ b/seed/ts-sdk/webhook-audience/tests/unit/fetcher/Fetcher.test.ts @@ -0,0 +1,262 @@ +import fs from "fs"; +import { join } from "path"; +import stream from "stream"; +import type { BinaryResponse } from "../../../src/core"; +import { type Fetcher, fetcherImpl } from "../../../src/core/fetcher/Fetcher"; + +describe("Test fetcherImpl", () => { + it("should handle successful request", async () => { + const mockArgs: Fetcher.Args = { + url: "https://httpbin.org/post", + method: "POST", + headers: { "X-Test": "x-test-header" }, + body: { data: "test" }, + contentType: "application/json", + requestType: "json", + maxRetries: 0, + responseType: "json", + }; + + global.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ data: "test" }), { + status: 200, + statusText: "OK", + }), + ); + + const result = await fetcherImpl(mockArgs); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.body).toEqual({ data: "test" }); + } + + expect(global.fetch).toHaveBeenCalledWith( + "https://httpbin.org/post", + expect.objectContaining({ + method: "POST", + headers: expect.toContainHeaders({ "X-Test": "x-test-header" }), + body: JSON.stringify({ data: "test" }), + }), + ); + }); + + it("should send octet stream", async () => { + const url = "https://httpbin.org/post/file"; + const mockArgs: Fetcher.Args = { + url, + method: "POST", + headers: { "X-Test": "x-test-header" }, + contentType: "application/octet-stream", + requestType: "bytes", + maxRetries: 0, + responseType: "json", + body: fs.createReadStream(join(__dirname, "test-file.txt")), + }; + + global.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ data: "test" }), { + status: 200, + statusText: "OK", + }), + ); + + const result = await fetcherImpl(mockArgs); + + expect(global.fetch).toHaveBeenCalledWith( + url, + expect.objectContaining({ + method: "POST", + headers: expect.toContainHeaders({ "X-Test": "x-test-header" }), + body: expect.any(fs.ReadStream), + }), + ); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.body).toEqual({ data: "test" }); + } + }); + + it("should receive file as stream", async () => { + const url = "https://httpbin.org/post/file"; + const mockArgs: Fetcher.Args = { + url, + method: "GET", + headers: { "X-Test": "x-test-header" }, + maxRetries: 0, + responseType: "binary-response", + }; + + global.fetch = vi.fn().mockResolvedValue( + new Response( + stream.Readable.toWeb(fs.createReadStream(join(__dirname, "test-file.txt"))) as ReadableStream, + { + status: 200, + statusText: "OK", + }, + ), + ); + + const result = await fetcherImpl(mockArgs); + + expect(global.fetch).toHaveBeenCalledWith( + url, + expect.objectContaining({ + method: "GET", + headers: expect.toContainHeaders({ "X-Test": "x-test-header" }), + }), + ); + expect(result.ok).toBe(true); + if (result.ok) { + const body = result.body as BinaryResponse; + expect(body).toBeDefined(); + expect(body.bodyUsed).toBe(false); + expect(typeof body.stream).toBe("function"); + const stream = body.stream(); + expect(stream).toBeInstanceOf(ReadableStream); + const readableStream = stream as ReadableStream; + const reader = readableStream.getReader(); + const { value } = await reader.read(); + const decoder = new TextDecoder(); + const streamContent = decoder.decode(value); + expect(streamContent.trim()).toBe("This is a test file!"); + expect(body.bodyUsed).toBe(true); + } + }); + + it("should receive file as blob", async () => { + const url = "https://httpbin.org/post/file"; + const mockArgs: Fetcher.Args = { + url, + method: "GET", + headers: { "X-Test": "x-test-header" }, + maxRetries: 0, + responseType: "binary-response", + }; + + global.fetch = vi.fn().mockResolvedValue( + new Response( + stream.Readable.toWeb(fs.createReadStream(join(__dirname, "test-file.txt"))) as ReadableStream, + { + status: 200, + statusText: "OK", + }, + ), + ); + + const result = await fetcherImpl(mockArgs); + + expect(global.fetch).toHaveBeenCalledWith( + url, + expect.objectContaining({ + method: "GET", + headers: expect.toContainHeaders({ "X-Test": "x-test-header" }), + }), + ); + expect(result.ok).toBe(true); + if (result.ok) { + const body = result.body as BinaryResponse; + expect(body).toBeDefined(); + expect(body.bodyUsed).toBe(false); + expect(typeof body.blob).toBe("function"); + const blob = await body.blob(); + expect(blob).toBeInstanceOf(Blob); + const reader = blob.stream().getReader(); + const { value } = await reader.read(); + const decoder = new TextDecoder(); + const streamContent = decoder.decode(value); + expect(streamContent.trim()).toBe("This is a test file!"); + expect(body.bodyUsed).toBe(true); + } + }); + + it("should receive file as arraybuffer", async () => { + const url = "https://httpbin.org/post/file"; + const mockArgs: Fetcher.Args = { + url, + method: "GET", + headers: { "X-Test": "x-test-header" }, + maxRetries: 0, + responseType: "binary-response", + }; + + global.fetch = vi.fn().mockResolvedValue( + new Response( + stream.Readable.toWeb(fs.createReadStream(join(__dirname, "test-file.txt"))) as ReadableStream, + { + status: 200, + statusText: "OK", + }, + ), + ); + + const result = await fetcherImpl(mockArgs); + + expect(global.fetch).toHaveBeenCalledWith( + url, + expect.objectContaining({ + method: "GET", + headers: expect.toContainHeaders({ "X-Test": "x-test-header" }), + }), + ); + expect(result.ok).toBe(true); + if (result.ok) { + const body = result.body as BinaryResponse; + expect(body).toBeDefined(); + expect(body.bodyUsed).toBe(false); + expect(typeof body.arrayBuffer).toBe("function"); + const arrayBuffer = await body.arrayBuffer(); + expect(arrayBuffer).toBeInstanceOf(ArrayBuffer); + const decoder = new TextDecoder(); + const streamContent = decoder.decode(new Uint8Array(arrayBuffer)); + expect(streamContent.trim()).toBe("This is a test file!"); + expect(body.bodyUsed).toBe(true); + } + }); + + it("should receive file as bytes", async () => { + const url = "https://httpbin.org/post/file"; + const mockArgs: Fetcher.Args = { + url, + method: "GET", + headers: { "X-Test": "x-test-header" }, + maxRetries: 0, + responseType: "binary-response", + }; + + global.fetch = vi.fn().mockResolvedValue( + new Response( + stream.Readable.toWeb(fs.createReadStream(join(__dirname, "test-file.txt"))) as ReadableStream, + { + status: 200, + statusText: "OK", + }, + ), + ); + + const result = await fetcherImpl(mockArgs); + + expect(global.fetch).toHaveBeenCalledWith( + url, + expect.objectContaining({ + method: "GET", + headers: expect.toContainHeaders({ "X-Test": "x-test-header" }), + }), + ); + expect(result.ok).toBe(true); + if (result.ok) { + const body = result.body as BinaryResponse; + expect(body).toBeDefined(); + expect(body.bodyUsed).toBe(false); + expect(typeof body.bytes).toBe("function"); + if (!body.bytes) { + return; + } + const bytes = await body.bytes(); + expect(bytes).toBeInstanceOf(Uint8Array); + const decoder = new TextDecoder(); + const streamContent = decoder.decode(bytes); + expect(streamContent.trim()).toBe("This is a test file!"); + expect(body.bodyUsed).toBe(true); + } + }); +}); diff --git a/seed/ts-sdk/webhook-audience/tests/unit/fetcher/HttpResponsePromise.test.ts b/seed/ts-sdk/webhook-audience/tests/unit/fetcher/HttpResponsePromise.test.ts new file mode 100644 index 000000000000..2ec008e581d8 --- /dev/null +++ b/seed/ts-sdk/webhook-audience/tests/unit/fetcher/HttpResponsePromise.test.ts @@ -0,0 +1,143 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { HttpResponsePromise } from "../../../src/core/fetcher/HttpResponsePromise"; +import type { RawResponse, WithRawResponse } from "../../../src/core/fetcher/RawResponse"; + +describe("HttpResponsePromise", () => { + const mockRawResponse: RawResponse = { + headers: new Headers(), + redirected: false, + status: 200, + statusText: "OK", + type: "basic" as ResponseType, + url: "https://example.com", + }; + const mockData = { id: "123", name: "test" }; + const mockWithRawResponse: WithRawResponse = { + data: mockData, + rawResponse: mockRawResponse, + }; + + describe("fromFunction", () => { + it("should create an HttpResponsePromise from a function", async () => { + const mockFn = vi + .fn<(arg1: string, arg2: string) => Promise>>() + .mockResolvedValue(mockWithRawResponse); + + const responsePromise = HttpResponsePromise.fromFunction(mockFn, "arg1", "arg2"); + + const result = await responsePromise; + expect(result).toEqual(mockData); + expect(mockFn).toHaveBeenCalledWith("arg1", "arg2"); + + const resultWithRawResponse = await responsePromise.withRawResponse(); + expect(resultWithRawResponse).toEqual({ + data: mockData, + rawResponse: mockRawResponse, + }); + }); + }); + + describe("fromPromise", () => { + it("should create an HttpResponsePromise from a promise", async () => { + const promise = Promise.resolve(mockWithRawResponse); + + const responsePromise = HttpResponsePromise.fromPromise(promise); + + const result = await responsePromise; + expect(result).toEqual(mockData); + + const resultWithRawResponse = await responsePromise.withRawResponse(); + expect(resultWithRawResponse).toEqual({ + data: mockData, + rawResponse: mockRawResponse, + }); + }); + }); + + describe("fromExecutor", () => { + it("should create an HttpResponsePromise from an executor function", async () => { + const responsePromise = HttpResponsePromise.fromExecutor((resolve) => { + resolve(mockWithRawResponse); + }); + + const result = await responsePromise; + expect(result).toEqual(mockData); + + const resultWithRawResponse = await responsePromise.withRawResponse(); + expect(resultWithRawResponse).toEqual({ + data: mockData, + rawResponse: mockRawResponse, + }); + }); + }); + + describe("fromResult", () => { + it("should create an HttpResponsePromise from a result", async () => { + const responsePromise = HttpResponsePromise.fromResult(mockWithRawResponse); + + const result = await responsePromise; + expect(result).toEqual(mockData); + + const resultWithRawResponse = await responsePromise.withRawResponse(); + expect(resultWithRawResponse).toEqual({ + data: mockData, + rawResponse: mockRawResponse, + }); + }); + }); + + describe("Promise methods", () => { + let responsePromise: HttpResponsePromise; + + beforeEach(() => { + responsePromise = HttpResponsePromise.fromResult(mockWithRawResponse); + }); + + it("should support then() method", async () => { + const result = await responsePromise.then((data) => ({ + ...data, + modified: true, + })); + + expect(result).toEqual({ + ...mockData, + modified: true, + }); + }); + + it("should support catch() method", async () => { + const errorResponsePromise = HttpResponsePromise.fromExecutor((_, reject) => { + reject(new Error("Test error")); + }); + + const catchSpy = vi.fn(); + await errorResponsePromise.catch(catchSpy); + + expect(catchSpy).toHaveBeenCalled(); + const error = catchSpy.mock.calls[0]?.[0]; + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe("Test error"); + }); + + it("should support finally() method", async () => { + const finallySpy = vi.fn(); + await responsePromise.finally(finallySpy); + + expect(finallySpy).toHaveBeenCalled(); + }); + }); + + describe("withRawResponse", () => { + it("should return both data and raw response", async () => { + const responsePromise = HttpResponsePromise.fromResult(mockWithRawResponse); + + const result = await responsePromise.withRawResponse(); + + expect(result).toEqual({ + data: mockData, + rawResponse: mockRawResponse, + }); + }); + }); +}); diff --git a/seed/ts-sdk/webhook-audience/tests/unit/fetcher/RawResponse.test.ts b/seed/ts-sdk/webhook-audience/tests/unit/fetcher/RawResponse.test.ts new file mode 100644 index 000000000000..375ee3f38064 --- /dev/null +++ b/seed/ts-sdk/webhook-audience/tests/unit/fetcher/RawResponse.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; + +import { toRawResponse } from "../../../src/core/fetcher/RawResponse"; + +describe("RawResponse", () => { + describe("toRawResponse", () => { + it("should convert Response to RawResponse by removing body, bodyUsed, and ok properties", () => { + const mockHeaders = new Headers({ "content-type": "application/json" }); + const mockResponse = { + body: "test body", + bodyUsed: false, + ok: true, + headers: mockHeaders, + redirected: false, + status: 200, + statusText: "OK", + type: "basic" as ResponseType, + url: "https://example.com", + }; + + const result = toRawResponse(mockResponse as unknown as Response); + + expect("body" in result).toBe(false); + expect("bodyUsed" in result).toBe(false); + expect("ok" in result).toBe(false); + expect(result.headers).toBe(mockHeaders); + expect(result.redirected).toBe(false); + expect(result.status).toBe(200); + expect(result.statusText).toBe("OK"); + expect(result.type).toBe("basic"); + expect(result.url).toBe("https://example.com"); + }); + }); +}); diff --git a/seed/ts-sdk/webhook-audience/tests/unit/fetcher/createRequestUrl.test.ts b/seed/ts-sdk/webhook-audience/tests/unit/fetcher/createRequestUrl.test.ts new file mode 100644 index 000000000000..a92f1b5e81d1 --- /dev/null +++ b/seed/ts-sdk/webhook-audience/tests/unit/fetcher/createRequestUrl.test.ts @@ -0,0 +1,163 @@ +import { createRequestUrl } from "../../../src/core/fetcher/createRequestUrl"; + +describe("Test createRequestUrl", () => { + const BASE_URL = "https://api.example.com"; + + interface TestCase { + description: string; + baseUrl: string; + queryParams?: Record; + expected: string; + } + + const testCases: TestCase[] = [ + { + description: "should return the base URL when no query parameters are provided", + baseUrl: BASE_URL, + expected: BASE_URL, + }, + { + description: "should append simple query parameters", + baseUrl: BASE_URL, + queryParams: { key: "value", another: "param" }, + expected: "https://api.example.com?key=value&another=param", + }, + { + description: "should handle array query parameters", + baseUrl: BASE_URL, + queryParams: { items: ["a", "b", "c"] }, + expected: "https://api.example.com?items=a&items=b&items=c", + }, + { + description: "should handle object query parameters", + baseUrl: BASE_URL, + queryParams: { filter: { name: "John", age: 30 } }, + expected: "https://api.example.com?filter%5Bname%5D=John&filter%5Bage%5D=30", + }, + { + description: "should handle mixed types of query parameters", + baseUrl: BASE_URL, + queryParams: { + simple: "value", + array: ["x", "y"], + object: { key: "value" }, + }, + expected: "https://api.example.com?simple=value&array=x&array=y&object%5Bkey%5D=value", + }, + { + description: "should handle empty query parameters object", + baseUrl: BASE_URL, + queryParams: {}, + expected: BASE_URL, + }, + { + description: "should encode special characters in query parameters", + baseUrl: BASE_URL, + queryParams: { special: "a&b=c d" }, + expected: "https://api.example.com?special=a%26b%3Dc%20d", + }, + { + description: "should handle numeric values", + baseUrl: BASE_URL, + queryParams: { count: 42, price: 19.99, active: 1, inactive: 0 }, + expected: "https://api.example.com?count=42&price=19.99&active=1&inactive=0", + }, + { + description: "should handle boolean values", + baseUrl: BASE_URL, + queryParams: { enabled: true, disabled: false }, + expected: "https://api.example.com?enabled=true&disabled=false", + }, + { + description: "should handle null and undefined values", + baseUrl: BASE_URL, + queryParams: { + valid: "value", + nullValue: null, + undefinedValue: undefined, + emptyString: "", + }, + expected: "https://api.example.com?valid=value&nullValue=&emptyString=", + }, + { + description: "should handle deeply nested objects", + baseUrl: BASE_URL, + queryParams: { + user: { + profile: { + name: "John", + settings: { theme: "dark" }, + }, + }, + }, + expected: + "https://api.example.com?user%5Bprofile%5D%5Bname%5D=John&user%5Bprofile%5D%5Bsettings%5D%5Btheme%5D=dark", + }, + { + description: "should handle arrays of objects", + baseUrl: BASE_URL, + queryParams: { + users: [ + { name: "John", age: 30 }, + { name: "Jane", age: 25 }, + ], + }, + expected: + "https://api.example.com?users%5Bname%5D=John&users%5Bage%5D=30&users%5Bname%5D=Jane&users%5Bage%5D=25", + }, + { + description: "should handle mixed arrays", + baseUrl: BASE_URL, + queryParams: { + mixed: ["string", 42, true, { key: "value" }], + }, + expected: "https://api.example.com?mixed=string&mixed=42&mixed=true&mixed%5Bkey%5D=value", + }, + { + description: "should handle empty arrays", + baseUrl: BASE_URL, + queryParams: { emptyArray: [] }, + expected: BASE_URL, + }, + { + description: "should handle empty objects", + baseUrl: BASE_URL, + queryParams: { emptyObject: {} }, + expected: BASE_URL, + }, + { + description: "should handle special characters in keys", + baseUrl: BASE_URL, + queryParams: { "key with spaces": "value", "key[with]brackets": "value" }, + expected: "https://api.example.com?key%20with%20spaces=value&key%5Bwith%5Dbrackets=value", + }, + { + description: "should handle URL with existing query parameters", + baseUrl: "https://api.example.com?existing=param", + queryParams: { new: "value" }, + expected: "https://api.example.com?existing=param?new=value", + }, + { + description: "should handle complex nested structures", + baseUrl: BASE_URL, + queryParams: { + filters: { + status: ["active", "pending"], + category: { + type: "electronics", + subcategories: ["phones", "laptops"], + }, + }, + sort: { field: "name", direction: "asc" }, + }, + expected: + "https://api.example.com?filters%5Bstatus%5D=active&filters%5Bstatus%5D=pending&filters%5Bcategory%5D%5Btype%5D=electronics&filters%5Bcategory%5D%5Bsubcategories%5D=phones&filters%5Bcategory%5D%5Bsubcategories%5D=laptops&sort%5Bfield%5D=name&sort%5Bdirection%5D=asc", + }, + ]; + + testCases.forEach(({ description, baseUrl, queryParams, expected }) => { + it(description, () => { + expect(createRequestUrl(baseUrl, queryParams)).toBe(expected); + }); + }); +}); diff --git a/seed/ts-sdk/webhook-audience/tests/unit/fetcher/getRequestBody.test.ts b/seed/ts-sdk/webhook-audience/tests/unit/fetcher/getRequestBody.test.ts new file mode 100644 index 000000000000..8a6c3a57e211 --- /dev/null +++ b/seed/ts-sdk/webhook-audience/tests/unit/fetcher/getRequestBody.test.ts @@ -0,0 +1,129 @@ +import { getRequestBody } from "../../../src/core/fetcher/getRequestBody"; +import { RUNTIME } from "../../../src/core/runtime"; + +describe("Test getRequestBody", () => { + interface TestCase { + description: string; + input: any; + type: "json" | "form" | "file" | "bytes" | "other"; + expected: any; + skipCondition?: () => boolean; + } + + const testCases: TestCase[] = [ + { + description: "should stringify body if not FormData in Node environment", + input: { key: "value" }, + type: "json", + expected: '{"key":"value"}', + skipCondition: () => RUNTIME.type !== "node", + }, + { + description: "should stringify body if not FormData in browser environment", + input: { key: "value" }, + type: "json", + expected: '{"key":"value"}', + skipCondition: () => RUNTIME.type !== "browser", + }, + { + description: "should return the Uint8Array", + input: new Uint8Array([1, 2, 3]), + type: "bytes", + expected: new Uint8Array([1, 2, 3]), + }, + { + description: "should serialize objects for form-urlencoded content type", + input: { username: "johndoe", email: "john@example.com" }, + type: "form", + expected: "username=johndoe&email=john%40example.com", + }, + { + description: "should serialize complex nested objects and arrays for form-urlencoded content type", + input: { + user: { + profile: { + name: "John Doe", + settings: { + theme: "dark", + notifications: true, + }, + }, + tags: ["admin", "user"], + contacts: [ + { type: "email", value: "john@example.com" }, + { type: "phone", value: "+1234567890" }, + ], + }, + filters: { + status: ["active", "pending"], + metadata: { + created: "2024-01-01", + categories: ["electronics", "books"], + }, + }, + preferences: ["notifications", "updates"], + }, + type: "form", + expected: + "user%5Bprofile%5D%5Bname%5D=John%20Doe&" + + "user%5Bprofile%5D%5Bsettings%5D%5Btheme%5D=dark&" + + "user%5Bprofile%5D%5Bsettings%5D%5Bnotifications%5D=true&" + + "user%5Btags%5D=admin&" + + "user%5Btags%5D=user&" + + "user%5Bcontacts%5D%5Btype%5D=email&" + + "user%5Bcontacts%5D%5Bvalue%5D=john%40example.com&" + + "user%5Bcontacts%5D%5Btype%5D=phone&" + + "user%5Bcontacts%5D%5Bvalue%5D=%2B1234567890&" + + "filters%5Bstatus%5D=active&" + + "filters%5Bstatus%5D=pending&" + + "filters%5Bmetadata%5D%5Bcreated%5D=2024-01-01&" + + "filters%5Bmetadata%5D%5Bcategories%5D=electronics&" + + "filters%5Bmetadata%5D%5Bcategories%5D=books&" + + "preferences=notifications&" + + "preferences=updates", + }, + { + description: "should return the input for pre-serialized form-urlencoded strings", + input: "key=value&another=param", + type: "other", + expected: "key=value&another=param", + }, + { + description: "should JSON stringify objects", + input: { key: "value" }, + type: "json", + expected: '{"key":"value"}', + }, + ]; + + testCases.forEach(({ description, input, type, expected, skipCondition }) => { + it(description, async () => { + if (skipCondition?.()) { + return; + } + + const result = await getRequestBody({ + body: input, + type, + }); + + if (input instanceof Uint8Array) { + expect(result).toBe(input); + } else { + expect(result).toBe(expected); + } + }); + }); + + it("should return FormData in browser environment", async () => { + if (RUNTIME.type === "browser") { + const formData = new FormData(); + formData.append("key", "value"); + const result = await getRequestBody({ + body: formData, + type: "file", + }); + expect(result).toBe(formData); + } + }); +}); diff --git a/seed/ts-sdk/webhook-audience/tests/unit/fetcher/getResponseBody.test.ts b/seed/ts-sdk/webhook-audience/tests/unit/fetcher/getResponseBody.test.ts new file mode 100644 index 000000000000..ad6be7fc2c9b --- /dev/null +++ b/seed/ts-sdk/webhook-audience/tests/unit/fetcher/getResponseBody.test.ts @@ -0,0 +1,97 @@ +import { getResponseBody } from "../../../src/core/fetcher/getResponseBody"; + +import { RUNTIME } from "../../../src/core/runtime"; + +describe("Test getResponseBody", () => { + interface SimpleTestCase { + description: string; + responseData: string | Record; + responseType?: "blob" | "sse" | "streaming" | "text"; + expected: any; + skipCondition?: () => boolean; + } + + const simpleTestCases: SimpleTestCase[] = [ + { + description: "should handle text response type", + responseData: "test text", + responseType: "text", + expected: "test text", + }, + { + description: "should handle JSON response", + responseData: { key: "value" }, + expected: { key: "value" }, + }, + { + description: "should handle empty response", + responseData: "", + expected: undefined, + }, + { + description: "should handle non-JSON response", + responseData: "invalid json", + expected: { + ok: false, + error: { + reason: "non-json", + statusCode: 200, + rawBody: "invalid json", + }, + }, + }, + ]; + + simpleTestCases.forEach(({ description, responseData, responseType, expected, skipCondition }) => { + it(description, async () => { + if (skipCondition?.()) { + return; + } + + const mockResponse = new Response( + typeof responseData === "string" ? responseData : JSON.stringify(responseData), + ); + const result = await getResponseBody(mockResponse, responseType); + expect(result).toEqual(expected); + }); + }); + + it("should handle blob response type", async () => { + const mockBlob = new Blob(["test"], { type: "text/plain" }); + const mockResponse = new Response(mockBlob); + const result = await getResponseBody(mockResponse, "blob"); + // @ts-expect-error + expect(result.constructor.name).toBe("Blob"); + }); + + it("should handle sse response type", async () => { + if (RUNTIME.type === "node") { + const mockStream = new ReadableStream(); + const mockResponse = new Response(mockStream); + const result = await getResponseBody(mockResponse, "sse"); + expect(result).toBe(mockStream); + } + }); + + it("should handle streaming response type", async () => { + const encoder = new TextEncoder(); + const testData = "test stream data"; + const mockStream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(testData)); + controller.close(); + }, + }); + + const mockResponse = new Response(mockStream); + const result = (await getResponseBody(mockResponse, "streaming")) as ReadableStream; + + expect(result).toBeInstanceOf(ReadableStream); + + const reader = result.getReader(); + const decoder = new TextDecoder(); + const { value } = await reader.read(); + const streamContent = decoder.decode(value); + expect(streamContent).toBe(testData); + }); +}); diff --git a/seed/ts-sdk/webhook-audience/tests/unit/fetcher/logging.test.ts b/seed/ts-sdk/webhook-audience/tests/unit/fetcher/logging.test.ts new file mode 100644 index 000000000000..366c9b6ced61 --- /dev/null +++ b/seed/ts-sdk/webhook-audience/tests/unit/fetcher/logging.test.ts @@ -0,0 +1,517 @@ +import { fetcherImpl } from "../../../src/core/fetcher/Fetcher"; + +function createMockLogger() { + return { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; +} + +function mockSuccessResponse(data: unknown = { data: "test" }, status = 200, statusText = "OK") { + global.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify(data), { + status, + statusText, + }), + ); +} + +function mockErrorResponse(data: unknown = { error: "Error" }, status = 404, statusText = "Not Found") { + global.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify(data), { + status, + statusText, + }), + ); +} + +describe("Fetcher Logging Integration", () => { + describe("Request Logging", () => { + it("should log successful request at debug level", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { test: "data" }, + contentType: "application/json", + requestType: "json", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + method: "POST", + url: "https://example.com/api", + headers: expect.toContainHeaders({ + "Content-Type": "application/json", + }), + hasBody: true, + }), + ); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "HTTP request succeeded", + expect.objectContaining({ + method: "POST", + url: "https://example.com/api", + statusCode: 200, + }), + ); + }); + + it("should not log debug messages at info level for successful requests", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "info", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).not.toHaveBeenCalled(); + expect(mockLogger.info).not.toHaveBeenCalled(); + }); + + it("should log request with body flag", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "POST", + body: { data: "test" }, + contentType: "application/json", + requestType: "json", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + hasBody: true, + }), + ); + }); + + it("should log request without body flag", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + hasBody: false, + }), + ); + }); + + it("should not log when silent mode is enabled", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: true, + }, + }); + + expect(mockLogger.debug).not.toHaveBeenCalled(); + expect(mockLogger.info).not.toHaveBeenCalled(); + expect(mockLogger.warn).not.toHaveBeenCalled(); + expect(mockLogger.error).not.toHaveBeenCalled(); + }); + + it("should not log when no logging config is provided", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + responseType: "json", + maxRetries: 0, + }); + + expect(mockLogger.debug).not.toHaveBeenCalled(); + }); + }); + + describe("Error Logging", () => { + it("should log 4xx errors at error level", async () => { + const mockLogger = createMockLogger(); + mockErrorResponse({ error: "Not found" }, 404, "Not Found"); + + const result = await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "error", + logger: mockLogger, + silent: false, + }, + }); + + expect(result.ok).toBe(false); + expect(mockLogger.error).toHaveBeenCalledWith( + "HTTP request failed with error status", + expect.objectContaining({ + method: "GET", + url: "https://example.com/api", + statusCode: 404, + }), + ); + }); + + it("should log 5xx errors at error level", async () => { + const mockLogger = createMockLogger(); + mockErrorResponse({ error: "Internal error" }, 500, "Internal Server Error"); + + const result = await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "error", + logger: mockLogger, + silent: false, + }, + }); + + expect(result.ok).toBe(false); + expect(mockLogger.error).toHaveBeenCalledWith( + "HTTP request failed with error status", + expect.objectContaining({ + method: "GET", + url: "https://example.com/api", + statusCode: 500, + }), + ); + }); + + it("should log aborted request errors", async () => { + const mockLogger = createMockLogger(); + + const abortController = new AbortController(); + abortController.abort(); + + global.fetch = vi.fn().mockRejectedValue(new Error("Aborted")); + + const result = await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + responseType: "json", + abortSignal: abortController.signal, + maxRetries: 0, + logging: { + level: "error", + logger: mockLogger, + silent: false, + }, + }); + + expect(result.ok).toBe(false); + expect(mockLogger.error).toHaveBeenCalledWith( + "HTTP request was aborted", + expect.objectContaining({ + method: "GET", + url: "https://example.com/api", + }), + ); + }); + + it("should log timeout errors", async () => { + const mockLogger = createMockLogger(); + + const timeoutError = new Error("Request timeout"); + timeoutError.name = "AbortError"; + + global.fetch = vi.fn().mockRejectedValue(timeoutError); + + const result = await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "error", + logger: mockLogger, + silent: false, + }, + }); + + expect(result.ok).toBe(false); + expect(mockLogger.error).toHaveBeenCalledWith( + "HTTP request timed out", + expect.objectContaining({ + method: "GET", + url: "https://example.com/api", + timeoutMs: undefined, + }), + ); + }); + + it("should log unknown errors", async () => { + const mockLogger = createMockLogger(); + + const unknownError = new Error("Unknown error"); + + global.fetch = vi.fn().mockRejectedValue(unknownError); + + const result = await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "error", + logger: mockLogger, + silent: false, + }, + }); + + expect(result.ok).toBe(false); + expect(mockLogger.error).toHaveBeenCalledWith( + "HTTP request failed with error", + expect.objectContaining({ + method: "GET", + url: "https://example.com/api", + errorMessage: "Unknown error", + }), + ); + }); + }); + + describe("Logging with Redaction", () => { + it("should redact sensitive data in error logs", async () => { + const mockLogger = createMockLogger(); + mockErrorResponse({ error: "Unauthorized" }, 401, "Unauthorized"); + + await fetcherImpl({ + url: "https://example.com/api?api_key=secret", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "error", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.error).toHaveBeenCalledWith( + "HTTP request failed with error status", + expect.objectContaining({ + url: "https://example.com/api?api_key=[REDACTED]", + }), + ); + }); + }); + + describe("Different HTTP Methods", () => { + it("should log GET requests", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + method: "GET", + }), + ); + }); + + it("should log POST requests", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse({ data: "test" }, 201, "Created"); + + await fetcherImpl({ + url: "https://example.com/api", + method: "POST", + body: { data: "test" }, + contentType: "application/json", + requestType: "json", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + method: "POST", + }), + ); + }); + + it("should log PUT requests", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "PUT", + body: { data: "test" }, + contentType: "application/json", + requestType: "json", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + method: "PUT", + }), + ); + }); + + it("should log DELETE requests", async () => { + const mockLogger = createMockLogger(); + global.fetch = vi.fn().mockResolvedValue( + new Response(null, { + status: 200, + statusText: "OK", + }), + ); + + await fetcherImpl({ + url: "https://example.com/api", + method: "DELETE", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + method: "DELETE", + }), + ); + }); + }); + + describe("Status Code Logging", () => { + it("should log 2xx success status codes", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse({ data: "test" }, 201, "Created"); + + await fetcherImpl({ + url: "https://example.com/api", + method: "POST", + body: { data: "test" }, + contentType: "application/json", + requestType: "json", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "HTTP request succeeded", + expect.objectContaining({ + statusCode: 201, + }), + ); + }); + + it("should log 3xx redirect status codes as success", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse({ data: "test" }, 301, "Moved Permanently"); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "HTTP request succeeded", + expect.objectContaining({ + statusCode: 301, + }), + ); + }); + }); +}); diff --git a/seed/ts-sdk/webhook-audience/tests/unit/fetcher/makeRequest.test.ts b/seed/ts-sdk/webhook-audience/tests/unit/fetcher/makeRequest.test.ts new file mode 100644 index 000000000000..ea49466a55fc --- /dev/null +++ b/seed/ts-sdk/webhook-audience/tests/unit/fetcher/makeRequest.test.ts @@ -0,0 +1,54 @@ +import type { Mock } from "vitest"; +import { makeRequest } from "../../../src/core/fetcher/makeRequest"; + +describe("Test makeRequest", () => { + const mockPostUrl = "https://httpbin.org/post"; + const mockGetUrl = "https://httpbin.org/get"; + const mockHeaders = { "Content-Type": "application/json" }; + const mockBody = JSON.stringify({ key: "value" }); + + let mockFetch: Mock; + + beforeEach(() => { + mockFetch = vi.fn(); + mockFetch.mockResolvedValue(new Response(JSON.stringify({ test: "successful" }), { status: 200 })); + }); + + it("should handle POST request correctly", async () => { + const response = await makeRequest(mockFetch, mockPostUrl, "POST", mockHeaders, mockBody); + const responseBody = await response.json(); + expect(responseBody).toEqual({ test: "successful" }); + expect(mockFetch).toHaveBeenCalledTimes(1); + const [calledUrl, calledOptions] = mockFetch.mock.calls[0]; + expect(calledUrl).toBe(mockPostUrl); + expect(calledOptions).toEqual( + expect.objectContaining({ + method: "POST", + headers: mockHeaders, + body: mockBody, + credentials: undefined, + }), + ); + expect(calledOptions.signal).toBeDefined(); + expect(calledOptions.signal).toBeInstanceOf(AbortSignal); + }); + + it("should handle GET request correctly", async () => { + const response = await makeRequest(mockFetch, mockGetUrl, "GET", mockHeaders, undefined); + const responseBody = await response.json(); + expect(responseBody).toEqual({ test: "successful" }); + expect(mockFetch).toHaveBeenCalledTimes(1); + const [calledUrl, calledOptions] = mockFetch.mock.calls[0]; + expect(calledUrl).toBe(mockGetUrl); + expect(calledOptions).toEqual( + expect.objectContaining({ + method: "GET", + headers: mockHeaders, + body: undefined, + credentials: undefined, + }), + ); + expect(calledOptions.signal).toBeDefined(); + expect(calledOptions.signal).toBeInstanceOf(AbortSignal); + }); +}); diff --git a/seed/ts-sdk/webhook-audience/tests/unit/fetcher/redacting.test.ts b/seed/ts-sdk/webhook-audience/tests/unit/fetcher/redacting.test.ts new file mode 100644 index 000000000000..d599376b9bcf --- /dev/null +++ b/seed/ts-sdk/webhook-audience/tests/unit/fetcher/redacting.test.ts @@ -0,0 +1,1115 @@ +import { fetcherImpl } from "../../../src/core/fetcher/Fetcher"; + +function createMockLogger() { + return { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; +} + +function mockSuccessResponse(data: unknown = { data: "test" }, status = 200, statusText = "OK") { + global.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify(data), { + status, + statusText, + }), + ); +} + +describe("Redacting Logic", () => { + describe("Header Redaction", () => { + it("should redact authorization header", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + headers: { Authorization: "Bearer secret-token-12345" }, + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + headers: expect.toContainHeaders({ + Authorization: "[REDACTED]", + }), + }), + ); + }); + + it("should redact api-key header (case-insensitive)", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + headers: { "X-API-KEY": "secret-api-key" }, + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + headers: expect.toContainHeaders({ + "X-API-KEY": "[REDACTED]", + }), + }), + ); + }); + + it("should redact cookie header", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + headers: { Cookie: "session=abc123; token=xyz789" }, + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + headers: expect.toContainHeaders({ + Cookie: "[REDACTED]", + }), + }), + ); + }); + + it("should redact x-auth-token header", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + headers: { "x-auth-token": "auth-token-12345" }, + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + headers: expect.toContainHeaders({ + "x-auth-token": "[REDACTED]", + }), + }), + ); + }); + + it("should redact proxy-authorization header", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + headers: { "Proxy-Authorization": "Basic credentials" }, + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + headers: expect.toContainHeaders({ + "Proxy-Authorization": "[REDACTED]", + }), + }), + ); + }); + + it("should redact x-csrf-token header", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + headers: { "X-CSRF-Token": "csrf-token-abc" }, + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + headers: expect.toContainHeaders({ + "X-CSRF-Token": "[REDACTED]", + }), + }), + ); + }); + + it("should redact www-authenticate header", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + headers: { "WWW-Authenticate": "Bearer realm=example" }, + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + headers: expect.toContainHeaders({ + "WWW-Authenticate": "[REDACTED]", + }), + }), + ); + }); + + it("should redact x-session-token header", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + headers: { "X-Session-Token": "session-token-xyz" }, + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + headers: expect.toContainHeaders({ + "X-Session-Token": "[REDACTED]", + }), + }), + ); + }); + + it("should not redact non-sensitive headers", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + headers: { + "Content-Type": "application/json", + "User-Agent": "Test/1.0", + Accept: "application/json", + }, + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + headers: expect.toContainHeaders({ + "Content-Type": "application/json", + "User-Agent": "Test/1.0", + Accept: "application/json", + }), + }), + ); + }); + + it("should redact multiple sensitive headers at once", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + headers: { + Authorization: "Bearer token", + "X-API-Key": "api-key", + Cookie: "session=123", + "Content-Type": "application/json", + }, + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + headers: expect.toContainHeaders({ + Authorization: "[REDACTED]", + "X-API-Key": "[REDACTED]", + Cookie: "[REDACTED]", + "Content-Type": "application/json", + }), + }), + ); + }); + }); + + describe("Response Header Redaction", () => { + it("should redact Set-Cookie in response headers", async () => { + const mockLogger = createMockLogger(); + + const mockHeaders = new Headers(); + mockHeaders.set("Set-Cookie", "session=abc123; HttpOnly; Secure"); + mockHeaders.set("Content-Type", "application/json"); + + global.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ data: "test" }), { + status: 200, + statusText: "OK", + headers: mockHeaders, + }), + ); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "HTTP request succeeded", + expect.objectContaining({ + responseHeaders: expect.toContainHeaders({ + "set-cookie": "[REDACTED]", + "content-type": "application/json", + }), + }), + ); + }); + + it("should redact authorization in response headers", async () => { + const mockLogger = createMockLogger(); + + const mockHeaders = new Headers(); + mockHeaders.set("Authorization", "Bearer token-123"); + mockHeaders.set("Content-Type", "application/json"); + + global.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ data: "test" }), { + status: 200, + statusText: "OK", + headers: mockHeaders, + }), + ); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "HTTP request succeeded", + expect.objectContaining({ + responseHeaders: expect.toContainHeaders({ + authorization: "[REDACTED]", + "content-type": "application/json", + }), + }), + ); + }); + + it("should redact response headers in error responses", async () => { + const mockLogger = createMockLogger(); + + const mockHeaders = new Headers(); + mockHeaders.set("WWW-Authenticate", "Bearer realm=example"); + mockHeaders.set("Content-Type", "application/json"); + + global.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + statusText: "Unauthorized", + headers: mockHeaders, + }), + ); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "error", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.error).toHaveBeenCalledWith( + "HTTP request failed with error status", + expect.objectContaining({ + responseHeaders: expect.toContainHeaders({ + "www-authenticate": "[REDACTED]", + "content-type": "application/json", + }), + }), + ); + }); + }); + + describe("Query Parameter Redaction", () => { + it("should redact api_key query parameter", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + queryParameters: { api_key: "secret-key" }, + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + queryParameters: expect.objectContaining({ + api_key: "[REDACTED]", + }), + }), + ); + }); + + it("should redact token query parameter", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + queryParameters: { token: "secret-token" }, + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + queryParameters: expect.objectContaining({ + token: "[REDACTED]", + }), + }), + ); + }); + + it("should redact access_token query parameter", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + queryParameters: { access_token: "secret-access-token" }, + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + queryParameters: expect.objectContaining({ + access_token: "[REDACTED]", + }), + }), + ); + }); + + it("should redact password query parameter", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + queryParameters: { password: "secret-password" }, + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + queryParameters: expect.objectContaining({ + password: "[REDACTED]", + }), + }), + ); + }); + + it("should redact secret query parameter", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + queryParameters: { secret: "secret-value" }, + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + queryParameters: expect.objectContaining({ + secret: "[REDACTED]", + }), + }), + ); + }); + + it("should redact session_id query parameter", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + queryParameters: { session_id: "session-123" }, + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + queryParameters: expect.objectContaining({ + session_id: "[REDACTED]", + }), + }), + ); + }); + + it("should not redact non-sensitive query parameters", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + queryParameters: { + page: "1", + limit: "10", + sort: "name", + }, + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + queryParameters: expect.objectContaining({ + page: "1", + limit: "10", + sort: "name", + }), + }), + ); + }); + + it("should not redact parameters containing 'auth' substring like 'author'", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + queryParameters: { + author: "john", + authenticate: "false", + authorization_level: "user", + }, + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + queryParameters: expect.objectContaining({ + author: "john", + authenticate: "false", + authorization_level: "user", + }), + }), + ); + }); + + it("should handle undefined query parameters", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + queryParameters: undefined, + }), + ); + }); + + it("should redact case-insensitive query parameters", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + queryParameters: { API_KEY: "secret-key", Token: "secret-token" }, + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + queryParameters: expect.objectContaining({ + API_KEY: "[REDACTED]", + Token: "[REDACTED]", + }), + }), + ); + }); + }); + + describe("URL Redaction", () => { + it("should redact credentials in URL", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://user:password@example.com/api", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + url: "https://[REDACTED]@example.com/api", + }), + ); + }); + + it("should redact api_key in query string", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api?api_key=secret-key&page=1", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + url: "https://example.com/api?api_key=[REDACTED]&page=1", + }), + ); + }); + + it("should redact token in query string", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api?token=secret-token", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + url: "https://example.com/api?token=[REDACTED]", + }), + ); + }); + + it("should redact password in query string", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api?username=user&password=secret", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + url: "https://example.com/api?username=user&password=[REDACTED]", + }), + ); + }); + + it("should not redact non-sensitive query strings", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api?page=1&limit=10&sort=name", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + url: "https://example.com/api?page=1&limit=10&sort=name", + }), + ); + }); + + it("should not redact URL parameters containing 'auth' substring like 'author'", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api?author=john&authenticate=false&page=1", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + url: "https://example.com/api?author=john&authenticate=false&page=1", + }), + ); + }); + + it("should handle URL with fragment", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api?token=secret#section", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + url: "https://example.com/api?token=[REDACTED]#section", + }), + ); + }); + + it("should redact URL-encoded query parameters", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api?api%5Fkey=secret", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + url: "https://example.com/api?api%5Fkey=[REDACTED]", + }), + ); + }); + + it("should handle URL without query string", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + url: "https://example.com/api", + }), + ); + }); + + it("should handle empty query string", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api?", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + url: "https://example.com/api?", + }), + ); + }); + + it("should redact multiple sensitive parameters in URL", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api?api_key=secret1&token=secret2&page=1", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + url: "https://example.com/api?api_key=[REDACTED]&token=[REDACTED]&page=1", + }), + ); + }); + + it("should redact both credentials and query parameters", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://user:pass@example.com/api?token=secret", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + url: "https://[REDACTED]@example.com/api?token=[REDACTED]", + }), + ); + }); + + it("should use fast path for URLs without sensitive keywords", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api?page=1&limit=10&sort=name&filter=value", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + url: "https://example.com/api?page=1&limit=10&sort=name&filter=value", + }), + ); + }); + + it("should handle query parameter without value", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api?flag&token=secret", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + url: "https://example.com/api?flag&token=[REDACTED]", + }), + ); + }); + + it("should handle URL with multiple @ symbols in credentials", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://user@example.com:pass@host.com/api", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + url: "https://[REDACTED]@host.com/api", + }), + ); + }); + + it("should handle URL with @ in query parameter but not in credentials", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api?email=user@example.com", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + url: "https://example.com/api?email=user@example.com", + }), + ); + }); + + it("should handle URL with both credentials and @ in path", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://user:pass@example.com/users/@username", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + url: "https://[REDACTED]@example.com/users/@username", + }), + ); + }); + }); +}); diff --git a/seed/ts-sdk/webhook-audience/tests/unit/fetcher/requestWithRetries.test.ts b/seed/ts-sdk/webhook-audience/tests/unit/fetcher/requestWithRetries.test.ts new file mode 100644 index 000000000000..d22661367f4e --- /dev/null +++ b/seed/ts-sdk/webhook-audience/tests/unit/fetcher/requestWithRetries.test.ts @@ -0,0 +1,230 @@ +import type { Mock, MockInstance } from "vitest"; +import { requestWithRetries } from "../../../src/core/fetcher/requestWithRetries"; + +describe("requestWithRetries", () => { + let mockFetch: Mock; + let originalMathRandom: typeof Math.random; + let setTimeoutSpy: MockInstance; + + beforeEach(() => { + mockFetch = vi.fn(); + originalMathRandom = Math.random; + + Math.random = vi.fn(() => 0.5); + + vi.useFakeTimers({ + toFake: [ + "setTimeout", + "clearTimeout", + "setInterval", + "clearInterval", + "setImmediate", + "clearImmediate", + "Date", + "performance", + "requestAnimationFrame", + "cancelAnimationFrame", + "requestIdleCallback", + "cancelIdleCallback", + ], + }); + }); + + afterEach(() => { + Math.random = originalMathRandom; + vi.clearAllMocks(); + vi.clearAllTimers(); + }); + + it("should retry on retryable status codes", async () => { + setTimeoutSpy = vi.spyOn(global, "setTimeout").mockImplementation((callback: (args: void) => void) => { + process.nextTick(callback); + return null as any; + }); + + const retryableStatuses = [408, 429, 500, 502]; + let callCount = 0; + + mockFetch.mockImplementation(async () => { + if (callCount < retryableStatuses.length) { + return new Response("", { status: retryableStatuses[callCount++] }); + } + return new Response("", { status: 200 }); + }); + + const responsePromise = requestWithRetries(() => mockFetch(), retryableStatuses.length); + await vi.runAllTimersAsync(); + const response = await responsePromise; + + expect(mockFetch).toHaveBeenCalledTimes(retryableStatuses.length + 1); + expect(response.status).toBe(200); + }); + + it("should respect maxRetries limit", async () => { + setTimeoutSpy = vi.spyOn(global, "setTimeout").mockImplementation((callback: (args: void) => void) => { + process.nextTick(callback); + return null as any; + }); + + const maxRetries = 2; + mockFetch.mockResolvedValue(new Response("", { status: 500 })); + + const responsePromise = requestWithRetries(() => mockFetch(), maxRetries); + await vi.runAllTimersAsync(); + const response = await responsePromise; + + expect(mockFetch).toHaveBeenCalledTimes(maxRetries + 1); + expect(response.status).toBe(500); + }); + + it("should not retry on success status codes", async () => { + setTimeoutSpy = vi.spyOn(global, "setTimeout").mockImplementation((callback: (args: void) => void) => { + process.nextTick(callback); + return null as any; + }); + + const successStatuses = [200, 201, 202]; + + for (const status of successStatuses) { + mockFetch.mockReset(); + setTimeoutSpy.mockClear(); + mockFetch.mockResolvedValueOnce(new Response("", { status })); + + const responsePromise = requestWithRetries(() => mockFetch(), 3); + await vi.runAllTimersAsync(); + await responsePromise; + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(setTimeoutSpy).not.toHaveBeenCalled(); + } + }); + + interface RetryHeaderTestCase { + description: string; + headerName: string; + headerValue: string | (() => string); + expectedDelayMin: number; + expectedDelayMax: number; + } + + const retryHeaderTests: RetryHeaderTestCase[] = [ + { + description: "should respect retry-after header with seconds value", + headerName: "retry-after", + headerValue: "5", + expectedDelayMin: 4000, + expectedDelayMax: 6000, + }, + { + description: "should respect retry-after header with HTTP date value", + headerName: "retry-after", + headerValue: () => new Date(Date.now() + 3000).toUTCString(), + expectedDelayMin: 2000, + expectedDelayMax: 4000, + }, + { + description: "should respect x-ratelimit-reset header", + headerName: "x-ratelimit-reset", + headerValue: () => Math.floor((Date.now() + 4000) / 1000).toString(), + expectedDelayMin: 3000, + expectedDelayMax: 6000, + }, + ]; + + retryHeaderTests.forEach(({ description, headerName, headerValue, expectedDelayMin, expectedDelayMax }) => { + it(description, async () => { + setTimeoutSpy = vi.spyOn(global, "setTimeout").mockImplementation((callback: (args: void) => void) => { + process.nextTick(callback); + return null as any; + }); + + const value = typeof headerValue === "function" ? headerValue() : headerValue; + mockFetch + .mockResolvedValueOnce( + new Response("", { + status: 429, + headers: new Headers({ [headerName]: value }), + }), + ) + .mockResolvedValueOnce(new Response("", { status: 200 })); + + const responsePromise = requestWithRetries(() => mockFetch(), 1); + await vi.runAllTimersAsync(); + const response = await responsePromise; + + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), expect.any(Number)); + const actualDelay = setTimeoutSpy.mock.calls[0][1]; + expect(actualDelay).toBeGreaterThan(expectedDelayMin); + expect(actualDelay).toBeLessThan(expectedDelayMax); + expect(response.status).toBe(200); + }); + }); + + it("should apply correct exponential backoff with jitter", async () => { + setTimeoutSpy = vi.spyOn(global, "setTimeout").mockImplementation((callback: (args: void) => void) => { + process.nextTick(callback); + return null as any; + }); + + mockFetch.mockResolvedValue(new Response("", { status: 500 })); + const maxRetries = 3; + const expectedDelays = [1000, 2000, 4000]; + + const responsePromise = requestWithRetries(() => mockFetch(), maxRetries); + await vi.runAllTimersAsync(); + await responsePromise; + + expect(setTimeoutSpy).toHaveBeenCalledTimes(expectedDelays.length); + + expectedDelays.forEach((delay, index) => { + expect(setTimeoutSpy).toHaveBeenNthCalledWith(index + 1, expect.any(Function), delay); + }); + + expect(mockFetch).toHaveBeenCalledTimes(maxRetries + 1); + }); + + it("should handle concurrent retries independently", async () => { + setTimeoutSpy = vi.spyOn(global, "setTimeout").mockImplementation((callback: (args: void) => void) => { + process.nextTick(callback); + return null as any; + }); + + mockFetch + .mockResolvedValueOnce(new Response("", { status: 500 })) + .mockResolvedValueOnce(new Response("", { status: 500 })) + .mockResolvedValueOnce(new Response("", { status: 200 })) + .mockResolvedValueOnce(new Response("", { status: 200 })); + + const promise1 = requestWithRetries(() => mockFetch(), 1); + const promise2 = requestWithRetries(() => mockFetch(), 1); + + await vi.runAllTimersAsync(); + const [response1, response2] = await Promise.all([promise1, promise2]); + + expect(response1.status).toBe(200); + expect(response2.status).toBe(200); + }); + + it("should cap delay at MAX_RETRY_DELAY for large header values", async () => { + setTimeoutSpy = vi.spyOn(global, "setTimeout").mockImplementation((callback: (args: void) => void) => { + process.nextTick(callback); + return null as any; + }); + + mockFetch + .mockResolvedValueOnce( + new Response("", { + status: 429, + headers: new Headers({ "retry-after": "120" }), // 120 seconds = 120000ms > MAX_RETRY_DELAY (60000ms) + }), + ) + .mockResolvedValueOnce(new Response("", { status: 200 })); + + const responsePromise = requestWithRetries(() => mockFetch(), 1); + await vi.runAllTimersAsync(); + const response = await responsePromise; + + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 60000); + expect(response.status).toBe(200); + }); +}); diff --git a/seed/ts-sdk/webhook-audience/tests/unit/fetcher/signals.test.ts b/seed/ts-sdk/webhook-audience/tests/unit/fetcher/signals.test.ts new file mode 100644 index 000000000000..d7b6d1e63caa --- /dev/null +++ b/seed/ts-sdk/webhook-audience/tests/unit/fetcher/signals.test.ts @@ -0,0 +1,69 @@ +import { anySignal, getTimeoutSignal } from "../../../src/core/fetcher/signals"; + +describe("Test getTimeoutSignal", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("should return an object with signal and abortId", () => { + const { signal, abortId } = getTimeoutSignal(1000); + + expect(signal).toBeDefined(); + expect(abortId).toBeDefined(); + expect(signal).toBeInstanceOf(AbortSignal); + expect(signal.aborted).toBe(false); + }); + + it("should create a signal that aborts after the specified timeout", () => { + const timeoutMs = 5000; + const { signal } = getTimeoutSignal(timeoutMs); + + expect(signal.aborted).toBe(false); + + vi.advanceTimersByTime(timeoutMs - 1); + expect(signal.aborted).toBe(false); + + vi.advanceTimersByTime(1); + expect(signal.aborted).toBe(true); + }); +}); + +describe("Test anySignal", () => { + it("should return an AbortSignal", () => { + const signal = anySignal(new AbortController().signal); + expect(signal).toBeInstanceOf(AbortSignal); + }); + + it("should abort when any of the input signals is aborted", () => { + const controller1 = new AbortController(); + const controller2 = new AbortController(); + const signal = anySignal(controller1.signal, controller2.signal); + + expect(signal.aborted).toBe(false); + controller1.abort(); + expect(signal.aborted).toBe(true); + }); + + it("should handle an array of signals", () => { + const controller1 = new AbortController(); + const controller2 = new AbortController(); + const signal = anySignal([controller1.signal, controller2.signal]); + + expect(signal.aborted).toBe(false); + controller2.abort(); + expect(signal.aborted).toBe(true); + }); + + it("should abort immediately if one of the input signals is already aborted", () => { + const controller1 = new AbortController(); + const controller2 = new AbortController(); + controller1.abort(); + + const signal = anySignal(controller1.signal, controller2.signal); + expect(signal.aborted).toBe(true); + }); +}); diff --git a/seed/ts-sdk/webhook-audience/tests/unit/fetcher/test-file.txt b/seed/ts-sdk/webhook-audience/tests/unit/fetcher/test-file.txt new file mode 100644 index 000000000000..c66d471e359c --- /dev/null +++ b/seed/ts-sdk/webhook-audience/tests/unit/fetcher/test-file.txt @@ -0,0 +1 @@ +This is a test file! diff --git a/seed/ts-sdk/webhook-audience/tests/unit/logging/logger.test.ts b/seed/ts-sdk/webhook-audience/tests/unit/logging/logger.test.ts new file mode 100644 index 000000000000..2e0b5fe5040c --- /dev/null +++ b/seed/ts-sdk/webhook-audience/tests/unit/logging/logger.test.ts @@ -0,0 +1,454 @@ +import { ConsoleLogger, createLogger, Logger, LogLevel } from "../../../src/core/logging/logger"; + +function createMockLogger() { + return { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; +} + +describe("Logger", () => { + describe("LogLevel", () => { + it("should have correct log levels", () => { + expect(LogLevel.Debug).toBe("debug"); + expect(LogLevel.Info).toBe("info"); + expect(LogLevel.Warn).toBe("warn"); + expect(LogLevel.Error).toBe("error"); + }); + }); + + describe("ConsoleLogger", () => { + let consoleLogger: ConsoleLogger; + let consoleSpy: { + debug: ReturnType; + info: ReturnType; + warn: ReturnType; + error: ReturnType; + }; + + beforeEach(() => { + consoleLogger = new ConsoleLogger(); + consoleSpy = { + debug: vi.spyOn(console, "debug").mockImplementation(() => {}), + info: vi.spyOn(console, "info").mockImplementation(() => {}), + warn: vi.spyOn(console, "warn").mockImplementation(() => {}), + error: vi.spyOn(console, "error").mockImplementation(() => {}), + }; + }); + + afterEach(() => { + consoleSpy.debug.mockRestore(); + consoleSpy.info.mockRestore(); + consoleSpy.warn.mockRestore(); + consoleSpy.error.mockRestore(); + }); + + it("should log debug messages", () => { + consoleLogger.debug("debug message", { data: "test" }); + expect(consoleSpy.debug).toHaveBeenCalledWith("debug message", { data: "test" }); + }); + + it("should log info messages", () => { + consoleLogger.info("info message", { data: "test" }); + expect(consoleSpy.info).toHaveBeenCalledWith("info message", { data: "test" }); + }); + + it("should log warn messages", () => { + consoleLogger.warn("warn message", { data: "test" }); + expect(consoleSpy.warn).toHaveBeenCalledWith("warn message", { data: "test" }); + }); + + it("should log error messages", () => { + consoleLogger.error("error message", { data: "test" }); + expect(consoleSpy.error).toHaveBeenCalledWith("error message", { data: "test" }); + }); + + it("should handle multiple arguments", () => { + consoleLogger.debug("message", "arg1", "arg2", { key: "value" }); + expect(consoleSpy.debug).toHaveBeenCalledWith("message", "arg1", "arg2", { key: "value" }); + }); + }); + + describe("Logger with level filtering", () => { + let mockLogger: { + debug: ReturnType; + info: ReturnType; + warn: ReturnType; + error: ReturnType; + }; + + beforeEach(() => { + mockLogger = createMockLogger(); + }); + + describe("Debug level", () => { + it("should log all levels when set to debug", () => { + const logger = new Logger({ + level: LogLevel.Debug, + logger: mockLogger, + silent: false, + }); + + logger.debug("debug"); + logger.info("info"); + logger.warn("warn"); + logger.error("error"); + + expect(mockLogger.debug).toHaveBeenCalledWith("debug"); + expect(mockLogger.info).toHaveBeenCalledWith("info"); + expect(mockLogger.warn).toHaveBeenCalledWith("warn"); + expect(mockLogger.error).toHaveBeenCalledWith("error"); + }); + + it("should report correct level checks", () => { + const logger = new Logger({ + level: LogLevel.Debug, + logger: mockLogger, + silent: false, + }); + + expect(logger.isDebug()).toBe(true); + expect(logger.isInfo()).toBe(true); + expect(logger.isWarn()).toBe(true); + expect(logger.isError()).toBe(true); + }); + }); + + describe("Info level", () => { + it("should log info, warn, and error when set to info", () => { + const logger = new Logger({ + level: LogLevel.Info, + logger: mockLogger, + silent: false, + }); + + logger.debug("debug"); + logger.info("info"); + logger.warn("warn"); + logger.error("error"); + + expect(mockLogger.debug).not.toHaveBeenCalled(); + expect(mockLogger.info).toHaveBeenCalledWith("info"); + expect(mockLogger.warn).toHaveBeenCalledWith("warn"); + expect(mockLogger.error).toHaveBeenCalledWith("error"); + }); + + it("should report correct level checks", () => { + const logger = new Logger({ + level: LogLevel.Info, + logger: mockLogger, + silent: false, + }); + + expect(logger.isDebug()).toBe(false); + expect(logger.isInfo()).toBe(true); + expect(logger.isWarn()).toBe(true); + expect(logger.isError()).toBe(true); + }); + }); + + describe("Warn level", () => { + it("should log warn and error when set to warn", () => { + const logger = new Logger({ + level: LogLevel.Warn, + logger: mockLogger, + silent: false, + }); + + logger.debug("debug"); + logger.info("info"); + logger.warn("warn"); + logger.error("error"); + + expect(mockLogger.debug).not.toHaveBeenCalled(); + expect(mockLogger.info).not.toHaveBeenCalled(); + expect(mockLogger.warn).toHaveBeenCalledWith("warn"); + expect(mockLogger.error).toHaveBeenCalledWith("error"); + }); + + it("should report correct level checks", () => { + const logger = new Logger({ + level: LogLevel.Warn, + logger: mockLogger, + silent: false, + }); + + expect(logger.isDebug()).toBe(false); + expect(logger.isInfo()).toBe(false); + expect(logger.isWarn()).toBe(true); + expect(logger.isError()).toBe(true); + }); + }); + + describe("Error level", () => { + it("should only log error when set to error", () => { + const logger = new Logger({ + level: LogLevel.Error, + logger: mockLogger, + silent: false, + }); + + logger.debug("debug"); + logger.info("info"); + logger.warn("warn"); + logger.error("error"); + + expect(mockLogger.debug).not.toHaveBeenCalled(); + expect(mockLogger.info).not.toHaveBeenCalled(); + expect(mockLogger.warn).not.toHaveBeenCalled(); + expect(mockLogger.error).toHaveBeenCalledWith("error"); + }); + + it("should report correct level checks", () => { + const logger = new Logger({ + level: LogLevel.Error, + logger: mockLogger, + silent: false, + }); + + expect(logger.isDebug()).toBe(false); + expect(logger.isInfo()).toBe(false); + expect(logger.isWarn()).toBe(false); + expect(logger.isError()).toBe(true); + }); + }); + + describe("Silent mode", () => { + it("should not log anything when silent is true", () => { + const logger = new Logger({ + level: LogLevel.Debug, + logger: mockLogger, + silent: true, + }); + + logger.debug("debug"); + logger.info("info"); + logger.warn("warn"); + logger.error("error"); + + expect(mockLogger.debug).not.toHaveBeenCalled(); + expect(mockLogger.info).not.toHaveBeenCalled(); + expect(mockLogger.warn).not.toHaveBeenCalled(); + expect(mockLogger.error).not.toHaveBeenCalled(); + }); + + it("should report all level checks as false when silent", () => { + const logger = new Logger({ + level: LogLevel.Debug, + logger: mockLogger, + silent: true, + }); + + expect(logger.isDebug()).toBe(false); + expect(logger.isInfo()).toBe(false); + expect(logger.isWarn()).toBe(false); + expect(logger.isError()).toBe(false); + }); + }); + + describe("shouldLog", () => { + it("should correctly determine if level should be logged", () => { + const logger = new Logger({ + level: LogLevel.Info, + logger: mockLogger, + silent: false, + }); + + expect(logger.shouldLog(LogLevel.Debug)).toBe(false); + expect(logger.shouldLog(LogLevel.Info)).toBe(true); + expect(logger.shouldLog(LogLevel.Warn)).toBe(true); + expect(logger.shouldLog(LogLevel.Error)).toBe(true); + }); + + it("should return false for all levels when silent", () => { + const logger = new Logger({ + level: LogLevel.Debug, + logger: mockLogger, + silent: true, + }); + + expect(logger.shouldLog(LogLevel.Debug)).toBe(false); + expect(logger.shouldLog(LogLevel.Info)).toBe(false); + expect(logger.shouldLog(LogLevel.Warn)).toBe(false); + expect(logger.shouldLog(LogLevel.Error)).toBe(false); + }); + }); + + describe("Multiple arguments", () => { + it("should pass multiple arguments to logger", () => { + const logger = new Logger({ + level: LogLevel.Debug, + logger: mockLogger, + silent: false, + }); + + logger.debug("message", "arg1", { key: "value" }, 123); + expect(mockLogger.debug).toHaveBeenCalledWith("message", "arg1", { key: "value" }, 123); + }); + }); + }); + + describe("createLogger", () => { + it("should return default logger when no config provided", () => { + const logger = createLogger(); + expect(logger).toBeInstanceOf(Logger); + }); + + it("should return same logger instance when Logger is passed", () => { + const customLogger = new Logger({ + level: LogLevel.Debug, + logger: new ConsoleLogger(), + silent: false, + }); + + const result = createLogger(customLogger); + expect(result).toBe(customLogger); + }); + + it("should create logger with custom config", () => { + const mockLogger = createMockLogger(); + + const logger = createLogger({ + level: LogLevel.Warn, + logger: mockLogger, + silent: false, + }); + + expect(logger).toBeInstanceOf(Logger); + logger.warn("test"); + expect(mockLogger.warn).toHaveBeenCalledWith("test"); + }); + + it("should use default values for missing config", () => { + const logger = createLogger({}); + expect(logger).toBeInstanceOf(Logger); + }); + + it("should override default level", () => { + const mockLogger = createMockLogger(); + + const logger = createLogger({ + level: LogLevel.Debug, + logger: mockLogger, + silent: false, + }); + + logger.debug("test"); + expect(mockLogger.debug).toHaveBeenCalledWith("test"); + }); + + it("should override default silent mode", () => { + const mockLogger = createMockLogger(); + + const logger = createLogger({ + logger: mockLogger, + silent: false, + }); + + logger.info("test"); + expect(mockLogger.info).toHaveBeenCalledWith("test"); + }); + + it("should use provided logger implementation", () => { + const customLogger = createMockLogger(); + + const logger = createLogger({ + logger: customLogger, + level: LogLevel.Debug, + silent: false, + }); + + logger.debug("test"); + expect(customLogger.debug).toHaveBeenCalledWith("test"); + }); + + it("should default to silent: true", () => { + const mockLogger = createMockLogger(); + + const logger = createLogger({ + logger: mockLogger, + level: LogLevel.Debug, + }); + + logger.debug("test"); + expect(mockLogger.debug).not.toHaveBeenCalled(); + }); + }); + + describe("Default logger", () => { + it("should have silent: true by default", () => { + const logger = createLogger(); + expect(logger.shouldLog(LogLevel.Info)).toBe(false); + }); + + it("should not log when using default logger", () => { + const logger = createLogger(); + + logger.info("test"); + expect(logger.isInfo()).toBe(false); + }); + }); + + describe("Edge cases", () => { + it("should handle empty message", () => { + const mockLogger = createMockLogger(); + + const logger = new Logger({ + level: LogLevel.Debug, + logger: mockLogger, + silent: false, + }); + + logger.debug(""); + expect(mockLogger.debug).toHaveBeenCalledWith(""); + }); + + it("should handle no arguments", () => { + const mockLogger = createMockLogger(); + + const logger = new Logger({ + level: LogLevel.Debug, + logger: mockLogger, + silent: false, + }); + + logger.debug("message"); + expect(mockLogger.debug).toHaveBeenCalledWith("message"); + }); + + it("should handle complex objects", () => { + const mockLogger = createMockLogger(); + + const logger = new Logger({ + level: LogLevel.Debug, + logger: mockLogger, + silent: false, + }); + + const complexObject = { + nested: { key: "value" }, + array: [1, 2, 3], + fn: () => "test", + }; + + logger.debug("message", complexObject); + expect(mockLogger.debug).toHaveBeenCalledWith("message", complexObject); + }); + + it("should handle errors as arguments", () => { + const mockLogger = createMockLogger(); + + const logger = new Logger({ + level: LogLevel.Error, + logger: mockLogger, + silent: false, + }); + + const error = new Error("Test error"); + logger.error("Error occurred", error); + expect(mockLogger.error).toHaveBeenCalledWith("Error occurred", error); + }); + }); +}); diff --git a/seed/ts-sdk/webhook-audience/tests/unit/url/join.test.ts b/seed/ts-sdk/webhook-audience/tests/unit/url/join.test.ts new file mode 100644 index 000000000000..123488f084ea --- /dev/null +++ b/seed/ts-sdk/webhook-audience/tests/unit/url/join.test.ts @@ -0,0 +1,284 @@ +import { join } from "../../../src/core/url/index"; + +describe("join", () => { + interface TestCase { + description: string; + base: string; + segments: string[]; + expected: string; + } + + describe("basic functionality", () => { + const basicTests: TestCase[] = [ + { description: "should return empty string for empty base", base: "", segments: [], expected: "" }, + { + description: "should return empty string for empty base with path", + base: "", + segments: ["path"], + expected: "", + }, + { + description: "should handle single segment", + base: "base", + segments: ["segment"], + expected: "base/segment", + }, + { + description: "should handle single segment with trailing slash on base", + base: "base/", + segments: ["segment"], + expected: "base/segment", + }, + { + description: "should handle single segment with leading slash", + base: "base", + segments: ["/segment"], + expected: "base/segment", + }, + { + description: "should handle single segment with both slashes", + base: "base/", + segments: ["/segment"], + expected: "base/segment", + }, + { + description: "should handle multiple segments", + base: "base", + segments: ["path1", "path2", "path3"], + expected: "base/path1/path2/path3", + }, + { + description: "should handle multiple segments with slashes", + base: "base/", + segments: ["/path1/", "/path2/", "/path3/"], + expected: "base/path1/path2/path3/", + }, + ]; + + basicTests.forEach(({ description, base, segments, expected }) => { + it(description, () => { + expect(join(base, ...segments)).toBe(expected); + }); + }); + }); + + describe("URL handling", () => { + const urlTests: TestCase[] = [ + { + description: "should handle absolute URLs", + base: "https://example.com", + segments: ["api", "v1"], + expected: "https://example.com/api/v1", + }, + { + description: "should handle absolute URLs with slashes", + base: "https://example.com/", + segments: ["/api/", "/v1/"], + expected: "https://example.com/api/v1/", + }, + { + description: "should handle absolute URLs with base path", + base: "https://example.com/base", + segments: ["api", "v1"], + expected: "https://example.com/base/api/v1", + }, + { + description: "should preserve URL query parameters", + base: "https://example.com?query=1", + segments: ["api"], + expected: "https://example.com/api?query=1", + }, + { + description: "should preserve URL fragments", + base: "https://example.com#fragment", + segments: ["api"], + expected: "https://example.com/api#fragment", + }, + { + description: "should preserve URL query and fragments", + base: "https://example.com?query=1#fragment", + segments: ["api"], + expected: "https://example.com/api?query=1#fragment", + }, + { + description: "should handle http protocol", + base: "http://example.com", + segments: ["api"], + expected: "http://example.com/api", + }, + { + description: "should handle ftp protocol", + base: "ftp://example.com", + segments: ["files"], + expected: "ftp://example.com/files", + }, + { + description: "should handle ws protocol", + base: "ws://example.com", + segments: ["socket"], + expected: "ws://example.com/socket", + }, + { + description: "should fallback to path joining for malformed URLs", + base: "not-a-url://", + segments: ["path"], + expected: "not-a-url:///path", + }, + ]; + + urlTests.forEach(({ description, base, segments, expected }) => { + it(description, () => { + expect(join(base, ...segments)).toBe(expected); + }); + }); + }); + + describe("edge cases", () => { + const edgeCaseTests: TestCase[] = [ + { + description: "should handle empty segments", + base: "base", + segments: ["", "path"], + expected: "base/path", + }, + { + description: "should handle null segments", + base: "base", + segments: [null as any, "path"], + expected: "base/path", + }, + { + description: "should handle undefined segments", + base: "base", + segments: [undefined as any, "path"], + expected: "base/path", + }, + { + description: "should handle segments with only single slash", + base: "base", + segments: ["/", "path"], + expected: "base/path", + }, + { + description: "should handle segments with only double slash", + base: "base", + segments: ["//", "path"], + expected: "base/path", + }, + { + description: "should handle base paths with trailing slashes", + base: "base/", + segments: ["path"], + expected: "base/path", + }, + { + description: "should handle complex nested paths", + base: "api/v1/", + segments: ["/users/", "/123/", "/profile"], + expected: "api/v1/users/123/profile", + }, + ]; + + edgeCaseTests.forEach(({ description, base, segments, expected }) => { + it(description, () => { + expect(join(base, ...segments)).toBe(expected); + }); + }); + }); + + describe("real-world scenarios", () => { + const realWorldTests: TestCase[] = [ + { + description: "should handle API endpoint construction", + base: "https://api.example.com/v1", + segments: ["users", "123", "posts"], + expected: "https://api.example.com/v1/users/123/posts", + }, + { + description: "should handle file path construction", + base: "/var/www", + segments: ["html", "assets", "images"], + expected: "/var/www/html/assets/images", + }, + { + description: "should handle relative path construction", + base: "../parent", + segments: ["child", "grandchild"], + expected: "../parent/child/grandchild", + }, + { + description: "should handle Windows-style paths", + base: "C:\\Users", + segments: ["Documents", "file.txt"], + expected: "C:\\Users/Documents/file.txt", + }, + ]; + + realWorldTests.forEach(({ description, base, segments, expected }) => { + it(description, () => { + expect(join(base, ...segments)).toBe(expected); + }); + }); + }); + + describe("performance scenarios", () => { + it("should handle many segments efficiently", () => { + const segments = Array(100).fill("segment"); + const result = join("base", ...segments); + expect(result).toBe(`base/${segments.join("/")}`); + }); + + it("should handle long URLs", () => { + const longPath = "a".repeat(1000); + expect(join("https://example.com", longPath)).toBe(`https://example.com/${longPath}`); + }); + }); + + describe("trailing slash preservation", () => { + const trailingSlashTests: TestCase[] = [ + { + description: + "should preserve trailing slash on final result when base has trailing slash and no segments", + base: "https://api.example.com/", + segments: [], + expected: "https://api.example.com/", + }, + { + description: "should preserve trailing slash on v1 path", + base: "https://api.example.com/v1/", + segments: [], + expected: "https://api.example.com/v1/", + }, + { + description: "should preserve trailing slash when last segment has trailing slash", + base: "https://api.example.com", + segments: ["users/"], + expected: "https://api.example.com/users/", + }, + { + description: "should preserve trailing slash with relative path", + base: "api/v1", + segments: ["users/"], + expected: "api/v1/users/", + }, + { + description: "should preserve trailing slash with multiple segments", + base: "https://api.example.com", + segments: ["v1", "collections/"], + expected: "https://api.example.com/v1/collections/", + }, + { + description: "should preserve trailing slash with base path", + base: "base", + segments: ["path1", "path2/"], + expected: "base/path1/path2/", + }, + ]; + + trailingSlashTests.forEach(({ description, base, segments, expected }) => { + it(description, () => { + expect(join(base, ...segments)).toBe(expected); + }); + }); + }); +}); diff --git a/seed/ts-sdk/webhook-audience/tests/unit/url/qs.test.ts b/seed/ts-sdk/webhook-audience/tests/unit/url/qs.test.ts new file mode 100644 index 000000000000..42cdffb9e5ea --- /dev/null +++ b/seed/ts-sdk/webhook-audience/tests/unit/url/qs.test.ts @@ -0,0 +1,278 @@ +import { toQueryString } from "../../../src/core/url/index"; + +describe("Test qs toQueryString", () => { + interface BasicTestCase { + description: string; + input: any; + expected: string; + } + + describe("Basic functionality", () => { + const basicTests: BasicTestCase[] = [ + { description: "should return empty string for null", input: null, expected: "" }, + { description: "should return empty string for undefined", input: undefined, expected: "" }, + { description: "should return empty string for string primitive", input: "hello", expected: "" }, + { description: "should return empty string for number primitive", input: 42, expected: "" }, + { description: "should return empty string for true boolean", input: true, expected: "" }, + { description: "should return empty string for false boolean", input: false, expected: "" }, + { description: "should handle empty objects", input: {}, expected: "" }, + { + description: "should handle simple key-value pairs", + input: { name: "John", age: 30 }, + expected: "name=John&age=30", + }, + ]; + + basicTests.forEach(({ description, input, expected }) => { + it(description, () => { + expect(toQueryString(input)).toBe(expected); + }); + }); + }); + + describe("Array handling", () => { + interface ArrayTestCase { + description: string; + input: any; + options?: { arrayFormat?: "repeat" | "indices" }; + expected: string; + } + + const arrayTests: ArrayTestCase[] = [ + { + description: "should handle arrays with indices format (default)", + input: { items: ["a", "b", "c"] }, + expected: "items%5B0%5D=a&items%5B1%5D=b&items%5B2%5D=c", + }, + { + description: "should handle arrays with repeat format", + input: { items: ["a", "b", "c"] }, + options: { arrayFormat: "repeat" }, + expected: "items=a&items=b&items=c", + }, + { + description: "should handle empty arrays", + input: { items: [] }, + expected: "", + }, + { + description: "should handle arrays with mixed types", + input: { mixed: ["string", 42, true, false] }, + expected: "mixed%5B0%5D=string&mixed%5B1%5D=42&mixed%5B2%5D=true&mixed%5B3%5D=false", + }, + { + description: "should handle arrays with objects", + input: { users: [{ name: "John" }, { name: "Jane" }] }, + expected: "users%5B0%5D%5Bname%5D=John&users%5B1%5D%5Bname%5D=Jane", + }, + { + description: "should handle arrays with objects in repeat format", + input: { users: [{ name: "John" }, { name: "Jane" }] }, + options: { arrayFormat: "repeat" }, + expected: "users%5Bname%5D=John&users%5Bname%5D=Jane", + }, + ]; + + arrayTests.forEach(({ description, input, options, expected }) => { + it(description, () => { + expect(toQueryString(input, options)).toBe(expected); + }); + }); + }); + + describe("Nested objects", () => { + const nestedTests: BasicTestCase[] = [ + { + description: "should handle nested objects", + input: { user: { name: "John", age: 30 } }, + expected: "user%5Bname%5D=John&user%5Bage%5D=30", + }, + { + description: "should handle deeply nested objects", + input: { user: { profile: { name: "John", settings: { theme: "dark" } } } }, + expected: "user%5Bprofile%5D%5Bname%5D=John&user%5Bprofile%5D%5Bsettings%5D%5Btheme%5D=dark", + }, + { + description: "should handle empty nested objects", + input: { user: {} }, + expected: "", + }, + ]; + + nestedTests.forEach(({ description, input, expected }) => { + it(description, () => { + expect(toQueryString(input)).toBe(expected); + }); + }); + }); + + describe("Encoding", () => { + interface EncodingTestCase { + description: string; + input: any; + options?: { encode?: boolean }; + expected: string; + } + + const encodingTests: EncodingTestCase[] = [ + { + description: "should encode by default", + input: { name: "John Doe", email: "john@example.com" }, + expected: "name=John%20Doe&email=john%40example.com", + }, + { + description: "should not encode when encode is false", + input: { name: "John Doe", email: "john@example.com" }, + options: { encode: false }, + expected: "name=John Doe&email=john@example.com", + }, + { + description: "should encode special characters in keys", + input: { "user name": "John", "email[primary]": "john@example.com" }, + expected: "user%20name=John&email%5Bprimary%5D=john%40example.com", + }, + { + description: "should not encode special characters in keys when encode is false", + input: { "user name": "John", "email[primary]": "john@example.com" }, + options: { encode: false }, + expected: "user name=John&email[primary]=john@example.com", + }, + ]; + + encodingTests.forEach(({ description, input, options, expected }) => { + it(description, () => { + expect(toQueryString(input, options)).toBe(expected); + }); + }); + }); + + describe("Mixed scenarios", () => { + interface MixedTestCase { + description: string; + input: any; + options?: { arrayFormat?: "repeat" | "indices" }; + expected: string; + } + + const mixedTests: MixedTestCase[] = [ + { + description: "should handle complex nested structures", + input: { + filters: { + status: ["active", "pending"], + category: { + type: "electronics", + subcategories: ["phones", "laptops"], + }, + }, + sort: { field: "name", direction: "asc" }, + }, + expected: + "filters%5Bstatus%5D%5B0%5D=active&filters%5Bstatus%5D%5B1%5D=pending&filters%5Bcategory%5D%5Btype%5D=electronics&filters%5Bcategory%5D%5Bsubcategories%5D%5B0%5D=phones&filters%5Bcategory%5D%5Bsubcategories%5D%5B1%5D=laptops&sort%5Bfield%5D=name&sort%5Bdirection%5D=asc", + }, + { + description: "should handle complex nested structures with repeat format", + input: { + filters: { + status: ["active", "pending"], + category: { + type: "electronics", + subcategories: ["phones", "laptops"], + }, + }, + sort: { field: "name", direction: "asc" }, + }, + options: { arrayFormat: "repeat" }, + expected: + "filters%5Bstatus%5D=active&filters%5Bstatus%5D=pending&filters%5Bcategory%5D%5Btype%5D=electronics&filters%5Bcategory%5D%5Bsubcategories%5D=phones&filters%5Bcategory%5D%5Bsubcategories%5D=laptops&sort%5Bfield%5D=name&sort%5Bdirection%5D=asc", + }, + { + description: "should handle arrays with null/undefined values", + input: { items: ["a", null, "c", undefined, "e"] }, + expected: "items%5B0%5D=a&items%5B1%5D=&items%5B2%5D=c&items%5B4%5D=e", + }, + { + description: "should handle objects with null/undefined values", + input: { name: "John", age: null, email: undefined, active: true }, + expected: "name=John&age=&active=true", + }, + ]; + + mixedTests.forEach(({ description, input, options, expected }) => { + it(description, () => { + expect(toQueryString(input, options)).toBe(expected); + }); + }); + }); + + describe("Edge cases", () => { + const edgeCaseTests: BasicTestCase[] = [ + { + description: "should handle numeric keys", + input: { "0": "zero", "1": "one" }, + expected: "0=zero&1=one", + }, + { + description: "should handle boolean values in objects", + input: { enabled: true, disabled: false }, + expected: "enabled=true&disabled=false", + }, + { + description: "should handle empty strings", + input: { name: "", description: "test" }, + expected: "name=&description=test", + }, + { + description: "should handle zero values", + input: { count: 0, price: 0.0 }, + expected: "count=0&price=0", + }, + { + description: "should handle arrays with empty strings", + input: { items: ["a", "", "c"] }, + expected: "items%5B0%5D=a&items%5B1%5D=&items%5B2%5D=c", + }, + ]; + + edgeCaseTests.forEach(({ description, input, expected }) => { + it(description, () => { + expect(toQueryString(input)).toBe(expected); + }); + }); + }); + + describe("Options combinations", () => { + interface OptionsTestCase { + description: string; + input: any; + options?: { arrayFormat?: "repeat" | "indices"; encode?: boolean }; + expected: string; + } + + const optionsTests: OptionsTestCase[] = [ + { + description: "should respect both arrayFormat and encode options", + input: { items: ["a & b", "c & d"] }, + options: { arrayFormat: "repeat", encode: false }, + expected: "items=a & b&items=c & d", + }, + { + description: "should use default options when none provided", + input: { items: ["a", "b"] }, + expected: "items%5B0%5D=a&items%5B1%5D=b", + }, + { + description: "should merge provided options with defaults", + input: { items: ["a", "b"], name: "John Doe" }, + options: { encode: false }, + expected: "items[0]=a&items[1]=b&name=John Doe", + }, + ]; + + optionsTests.forEach(({ description, input, options, expected }) => { + it(description, () => { + expect(toQueryString(input, options)).toBe(expected); + }); + }); + }); +}); diff --git a/seed/ts-sdk/webhook-audience/tests/wire/.gitkeep b/seed/ts-sdk/webhook-audience/tests/wire/.gitkeep new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/seed/ts-sdk/webhook-audience/tsconfig.base.json b/seed/ts-sdk/webhook-audience/tsconfig.base.json new file mode 100644 index 000000000000..d7627675de20 --- /dev/null +++ b/seed/ts-sdk/webhook-audience/tsconfig.base.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "extendedDiagnostics": true, + "strict": true, + "target": "ES6", + "moduleResolution": "node", + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "baseUrl": "src", + "isolatedModules": true, + "isolatedDeclarations": true + }, + "include": ["src"], + "exclude": [] +} diff --git a/seed/ts-sdk/webhook-audience/tsconfig.cjs.json b/seed/ts-sdk/webhook-audience/tsconfig.cjs.json new file mode 100644 index 000000000000..5c11446f5984 --- /dev/null +++ b/seed/ts-sdk/webhook-audience/tsconfig.cjs.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "module": "CommonJS", + "outDir": "dist/cjs" + }, + "include": ["src"], + "exclude": [] +} diff --git a/seed/ts-sdk/webhook-audience/tsconfig.esm.json b/seed/ts-sdk/webhook-audience/tsconfig.esm.json new file mode 100644 index 000000000000..6ce909748b2c --- /dev/null +++ b/seed/ts-sdk/webhook-audience/tsconfig.esm.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "module": "esnext", + "outDir": "dist/esm", + "verbatimModuleSyntax": true + }, + "include": ["src"], + "exclude": [] +} diff --git a/seed/ts-sdk/webhook-audience/tsconfig.json b/seed/ts-sdk/webhook-audience/tsconfig.json new file mode 100644 index 000000000000..d77fdf00d259 --- /dev/null +++ b/seed/ts-sdk/webhook-audience/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./tsconfig.cjs.json" +} diff --git a/seed/ts-sdk/webhook-audience/vitest.config.mts b/seed/ts-sdk/webhook-audience/vitest.config.mts new file mode 100644 index 000000000000..ba2ec4f9d45a --- /dev/null +++ b/seed/ts-sdk/webhook-audience/vitest.config.mts @@ -0,0 +1,28 @@ +import { defineConfig } from "vitest/config"; +export default defineConfig({ + test: { + projects: [ + { + test: { + globals: true, + name: "unit", + environment: "node", + root: "./tests", + include: ["**/*.test.{js,ts,jsx,tsx}"], + exclude: ["wire/**"], + setupFiles: ["./setup.ts"], + }, + }, + { + test: { + globals: true, + name: "wire", + environment: "node", + root: "./tests/wire", + setupFiles: ["../setup.ts", "../mock-server/setup.ts"], + }, + }, + ], + passWithNoTests: true, + }, +}); diff --git a/test-definitions/fern/apis/python-streaming-parameter-openapi/openapi.yml b/test-definitions/fern/apis/python-streaming-parameter-openapi/openapi.yml index 5c489647b5fe..0b7d59b0ff80 100644 --- a/test-definitions/fern/apis/python-streaming-parameter-openapi/openapi.yml +++ b/test-definitions/fern/apis/python-streaming-parameter-openapi/openapi.yml @@ -12,21 +12,21 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ChatRequest' + $ref: "#/components/schemas/ChatRequest" responses: - '200': + "200": description: Successful response content: application/json: schema: - $ref: '#/components/schemas/ChatResponse' + $ref: "#/components/schemas/ChatResponse" x-fern-streaming: format: sse stream-condition: $request.stream response: - $ref: '#/components/schemas/ChatResponse' + $ref: "#/components/schemas/ChatResponse" response-stream: - $ref: '#/components/schemas/ChatStreamEvent' + $ref: "#/components/schemas/ChatStreamEvent" x-fern-examples: - name: default request: diff --git a/test-definitions/fern/apis/webhook-audience/generators.yml b/test-definitions/fern/apis/webhook-audience/generators.yml new file mode 100644 index 000000000000..8762d29b8370 --- /dev/null +++ b/test-definitions/fern/apis/webhook-audience/generators.yml @@ -0,0 +1,4 @@ +# yaml-language-server: $schema=https://schema.buildwithfern.dev/generators-yml.json +api: + specs: + - openapi: ./openapi.yml diff --git a/test-definitions/fern/apis/webhook-audience/openapi.yml b/test-definitions/fern/apis/webhook-audience/openapi.yml new file mode 100644 index 000000000000..50763d5048e4 --- /dev/null +++ b/test-definitions/fern/apis/webhook-audience/openapi.yml @@ -0,0 +1,69 @@ +openapi: 3.1.0 +info: + title: Webhook Audience Test + version: 1.0.0 + description: | + Tests that webhooks with x-fern-audiences are correctly included when + generating for matching audiences. This fixture verifies the fix for + webhook audience filtering in the v3 importer. +paths: + /public-webhook: + post: + operationId: public-webhook + x-fern-webhook: true + x-fern-audiences: + - public + x-fern-sdk-group-name: webhooks + x-fern-sdk-method-name: publicWebhook + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PublicPayload" + /private-webhook: + post: + operationId: private-webhook + x-fern-webhook: true + x-fern-audiences: + - private + x-fern-sdk-group-name: webhooks + x-fern-sdk-method-name: privateWebhook + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PrivatePayload" + /no-audience-webhook: + post: + operationId: no-audience-webhook + x-fern-webhook: true + x-fern-sdk-group-name: webhooks + x-fern-sdk-method-name: noAudienceWebhook + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/NoAudiencePayload" +components: + schemas: + PublicPayload: + type: object + properties: + message: + type: string + required: + - message + PrivatePayload: + type: object + properties: + secret: + type: string + required: + - secret + NoAudiencePayload: + type: object + properties: + data: + type: string + required: + - data