OptionalValues.Swashbuckle 0.3.4

There is a newer version of this package available.
See the version list below for details.
dotnet add package OptionalValues.Swashbuckle --version 0.3.4                
NuGet\Install-Package OptionalValues.Swashbuckle -Version 0.3.4                
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="OptionalValues.Swashbuckle" Version="0.3.4" />                
For projects that support PackageReference, copy this XML node into the project file to reference the package.
paket add OptionalValues.Swashbuckle --version 0.3.4                
#r "nuget: OptionalValues.Swashbuckle, 0.3.4"                
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
// Install OptionalValues.Swashbuckle as a Cake Addin
#addin nuget:?package=OptionalValues.Swashbuckle&version=0.3.4

// Install OptionalValues.Swashbuckle as a Cake Tool
#tool nuget:?package=OptionalValues.Swashbuckle&version=0.3.4                

OptionalValues

A .NET library that provides an OptionalValue<T> type, representing a value that may or may not be specified, with comprehensive support for JSON serialization. e.g. (undefined, null, "value")

NuGet License GitHub Actions Workflow Status

Package Version
OptionalValues NuGet
OptionalValues.Swashbuckle NuGet
OptionalValues.FluentValidation NuGet

Overview

The OptionalValue<T> struct is designed to represent a value that can be in one of three states:

  • Unspecified: The value has not been specified. (e.g. undefined)
  • Specified with a non-null value: The value has been specified and is not null.
  • Specified with a null value: The value has been specified and is null.

Why

When working with Json it's currently difficult to know whether a property was omitted or explicitly null. This makes it hard to support older clients that don't send all properties in a request. By using OptionalValue<T> you can distinguish between null and Unspecified values.

using System.Text.Json;
using OptionalValues;

var jsonSerializerOptions = new JsonSerializerOptions()
    .AddOptionalValueSupport();

var json =
    """
    {
      "FirstName": "John",
      "LastName": null
    }
    """;

var person1 = JsonSerializer.Deserialize<Person>(json, jsonSerializerOptions);

// equals:
var person2 = new Person
{
    FirstName = "John",
    LastName = null,
    Address = OptionalValue<string>.Unspecified // or default
};

bool areEqual = person1 == person2; // True

string serialized = JsonSerializer.Serialize(person2, jsonSerializerOptions);
// Output: {"FirstName":"John","LastName":null}

public record Person
{
    public OptionalValue<string> FirstName { get; set; }
    public OptionalValue<string?> LastName { get; set; }
    public OptionalValue<string> Address { get; set; }
}

Installation

Install the package using the .NET CLI:

dotnet add package OptionalValues

For JSON serialization support, configure the JsonSerializerOptions to include the OptionalValue<T> converter:

var options = new JsonSerializerOptions()
    .AddOptionalValueSupport();

Optionally, install one or more extension packages:

dotnet add package OptionalValues.FluentValidation
dotnet add package OptionalValues.Swashbuckle

Features

  • Distinguish Between Unspecified and Null Values: Clearly differentiate when a value is intentionally null versus when it has not been specified at all. This allows for mapping undefined values in JSON to Unspecified values in C#.
  • JSON Serialization Support: Includes a custom JSON converter and TypeResolverModifier that correctly handles serialization and deserialization, ensuring unspecified values are omitted from JSON outputs.
  • FluentValidation Extensions: Provides extension methods to simplify the validation of OptionalValue<T> properties using FluentValidation.
  • OpenApi/Swagger Support: Includes a custom data contract resolver for Swashbuckle to generate accurate OpenAPI/Swagger documentation.
  • Patch Operation Support: Ideal for API patch operations where fields can be updated to null or remain unchanged.

Table of Contents

Usage

Creating an OptionalValue

You can create an OptionalValue<T> in several ways:

  • Unspecified Value:

    var unspecified = new OptionalValue<string>();
    // or
    var unspecified = OptionalValue<string>.Unspecified;
    // or
    OptionalValue<string> unspecified = default;
    
  • Specified Value:

    var specifiedValue = new OptionalValue<string>("Hello, World!");
    // or using implicit conversion
    OptionalValue<string> specifiedValue = "Hello, World!";
    
  • Specified Null Value:

    var specifiedNull = new OptionalValue<string?>(null);
    // or using implicit conversion
    OptionalValue<string?> specifiedNull = null;
    

Checking If a Value Is Specified

Use the IsSpecified property to determine if the value has been specified:

if (optionalValue.IsSpecified)
{
    Console.WriteLine("Value is specified.");
}
else
{
    Console.WriteLine("Value is unspecified.");
}

Accessing the Value

  • .Value: Gets the value if specified; returns null if unspecified.
  • .SpecifiedValue: Gets the specified value; throws InvalidOperationException if the value is unspecified.
  • .GetSpecifiedValueOrDefault(): Gets the specified value or the default value of T if unspecified.
  • .GetSpecifiedValueOrDefault(T defaultValue): Gets the specified value or the provided default value if unspecified.
var optionalValue = new OptionalValue<string>("Example");

// Using Value
string? value = optionalValue.Value;

// Using SpecifiedValue
string specifiedValue = optionalValue.SpecifiedValue;

// Using GetSpecifiedValueOrDefault
string valueOrDefault = optionalValue.GetSpecifiedValueOrDefault("Default Value");

Implicit Conversions

OptionalValue<T> supports implicit conversions to and from T:

// From T to OptionalValue<T>
OptionalValue<int> optionalInt = 42;

// From OptionalValue<T> to T (returns null if unspecified)
int? value = optionalInt;

Equality Comparisons

Equality checks consider both the IsSpecified property and the Value:

var value1 = new OptionalValue<string>("Test");
var value2 = new OptionalValue<string>("Test");
var unspecified = new OptionalValue<string>();

bool areEqual = value1 == value2; // True
bool areUnspecifiedEqual = unspecified == new OptionalValue<string>(); // True

JSON Serialization with System.Text.Json

OptionalValue<T> includes a custom JSON converter and JsonTypeInfoResolver Modifier to handle serialization and deserialization of optional values. To properly serialize OptionalValue<T> properties, add it to the JsonSerializerOptions:

var newOptionsWithSupport = JsonSerializerOptions.Default
    .WithOptionalValueSupport();

// or
var options = new JsonSerializerOptions();
options.AddOptionalValueSupport();

Serialization Behavior

  • Unspecified Values: Omitted from the JSON output.
  • Specified Null Values: Serialized with a null value.
  • Specified Non-Null Values: Serialized with the actual value.
public class Person
{
    public OptionalValue<string> FirstName { get; set; }

    public OptionalValue<string> LastName { get; set; }
}

// Creating a Person instance
var person = new Person
{
    FirstName = "John", // Specified non-null value
    LastName = new OptionalValue<string>() // Unspecified
};

// Serializing to JSON
string json = JsonSerializer.Serialize(person);
// Output: {"FirstName":"John"}

Deserialization Behavior

  • Missing Properties: Deserialized as unspecified values.
  • Properties with null: Deserialized as specified with a null value.
  • Properties with Values: Deserialized as specified with the given value.
string jsonInput = @"{""FirstName"":""John"",""LastName"":null}";
var person = JsonSerializer.Deserialize<Person>(jsonInput);

bool isFirstNameSpecified = person.FirstName.IsSpecified; // True
string firstName = person.FirstName.SpecifiedValue; // "John"

bool isLastNameSpecified = person.LastName.IsSpecified; // True
string lastName = person.LastName.SpecifiedValue; // null

Library support

ASP.NET Core

The OptionalValues library integrates seamlessly with ASP.NET Core, allowing you to use OptionalValue<T> properties in your API models.

You only need to configure the JsonSerializerOptions to include the OptionalValue<T> converter:

// For Minimal API
builder.Services.ConfigureHttpJsonOptions(jsonOptions =>
{
    // Make sure that AddOptionalValueSupport() is the last call when you are using the `TypeInfoResolverChain` of the `SerializerOptions`.
    jsonOptions.SerializerOptions.AddOptionalValueSupport();
});

// For MVC
builder.Services.AddControllers()
    .AddJsonOptions(options =>
    {
        options.JsonSerializerOptions.AddOptionalValueSupport();
    });

Swashbuckle

The OptionalValues.Swashbuckle package provides a custom data contract resolver for Swashbuckle to generate accurate OpenAPI/Swagger documentation for OptionalValue<T> properties.

It correctly unwraps the OptionalValue<T> type and generates the appropriate schema for the underlying type T.

Installation

Install the package using the .NET CLI:

dotnet add package OptionalValues.Swashbuckle

Configure the Swashbuckle services to use the OptionalValueDataContractResolver:

builder.Services.AddSwaggerGen();
// after AddSwaggerGen when you want it to use an existing custom ISerializerDataContractResolver.
builder.Services.AddSwaggerGenOptionalValueSupport();

FluentValidation

The OptionalValues.FluentValidation package provides extension methods to simplify the validation of OptionalValue<T> properties using FluentValidation.

Installation

Install the package using the .NET CLI:

dotnet add package OptionalValues.FluentValidation

Using OptionalRuleFor

The OptionalRuleFor extension method allows you to define validation rules for OptionalValue<T> properties that are only applied when the value is specified.

using FluentValidation;
using OptionalValues.FluentValidation;

public class UpdateUserRequest
{
    public OptionalValue<string?> Email { get; set; }
    public OptionalValue<int> Age { get; set; }
}

public class UpdateUserRequestValidator : AbstractValidator<UpdateUserRequest>
{
    public UpdateUserRequestValidator()
    {
        this.OptionalRuleFor(x => x.Email, x => x
            .NotEmpty()
            .EmailAddress());

        this.OptionalRuleFor(x => x.Age, x => x
            .GreaterThan(18));
    }
}

In this example:

  • The validation rules for Email and Age are applied only if the corresponding OptionalValue<T> is specified.
  • If the value is unspecified, the validation rules are skipped.

How It Works

The OptionalRuleFor method:

  • Takes an expression specifying the OptionalValue<T> property.
  • Accepts a configuration function where you define your validation rules using the standard FluentValidation syntax.
  • Internally, it checks if the value is specified (IsSpecified) before applying the validation rules.

Example Usage

var validator = new UpdateUserRequestValidator();

// Valid request with specified values
var validRequest = new UpdateUserRequest
{
    Email = "user@example.com",
    Age = 25
};

var result = validator.Validate(validRequest);
// result.IsValid == true

// Invalid request with specified values
var invalidRequest = new UpdateUserRequest
{
    Email = "invalid-email",
    Age = 17
};

var resultInvalid = validator.Validate(invalidRequest);
// resultInvalid.IsValid == false
// Errors for Email and Age

// Request with unspecified values
var unspecifiedRequest = new UpdateUserRequest
{
    Email = default,
    Age = default
};

var resultUnspecified = validator.Validate(unspecifiedRequest);
// resultUnspecified.IsValid == true
// Validation rules are skipped for unspecified values

Use Cases

API Patch Operations

When updating resources via API endpoints, it's crucial to distinguish between fields that should be updated to null and fields that should remain unchanged.

public class UpdateUserRequest
{
    public OptionalValue<string?> Email { get; set; }

    public OptionalValue<string?> PhoneNumber { get; set; }
}

[HttpPatch("{id}")]
public IActionResult UpdateUser(int id, UpdateUserRequest request)
{
    if (request.Email.IsSpecified)
    {
        // Update email to request.Email.SpecifiedValue
    }

    if (request.PhoneNumber.IsSpecified)
    {
        // Update phone number to request.PhoneNumber.SpecifiedValue
    }

    // Unspecified fields remain unchanged

    return Ok();
}

Current Limitations

  • DataAnnotations: The OptionalValue<T> type does not support DataAnnotations validation attributes because they are tied to specific .NET Types (e.g. string).
    • "Workaround": Use the FluentValidation extensions to define validation rules for OptionalValue<T> properties.
  • Support for other libraries: Because OptionalValue<T> is a wrapper type it requires mapping to the underlying type for some libraries. Let me know if you have a specific library in mind that you would like to see support for.

Contributing

Contributions are welcome! Please feel free to submit issues or pull requests on the GitHub repository.

License

This project is licensed under the MIT License - see the LICENSE file for details.

Benchmarks

The project is benchmarked with BenchmarkDotNet to check any additional overhead that the OptionalValue<T> type might introduce. They are located in the /test/OptionalValues.Benchmarks directory.

Below are the results of the benchmarks for the OptionalValue<T> serialization performance on my machine:


BenchmarkDotNet v0.14.0, Windows 11 (10.0.26100.2605)
13th Gen Intel Core i9-13900H, 1 CPU, 20 logical and 14 physical cores
.NET SDK 9.0.101
  [Host]     : .NET 9.0.0 (9.0.24.52809), X64 RyuJIT AVX2
  DefaultJob : .NET 9.0.0 (9.0.24.52809), X64 RyuJIT AVX2


Method Mean Error StdDev Ratio RatioSD Gen0 Allocated Alloc Ratio
SerializePrimitiveModel 102.10 ns 1.296 ns 1.212 ns 1.00 0.02 0.0088 112 B 1.00
SerializeOptionalValueModel 108.55 ns 1.324 ns 1.238 ns 1.06 0.02 0.0134 168 B 1.50
SerializePrimitiveModelWithSourceGenerator 75.65 ns 1.554 ns 1.727 ns 0.74 0.02 0.0088 112 B 1.00
SerializeOptionalValueModelWithSourceGenerator 93.47 ns 1.690 ns 1.581 ns 0.92 0.02 0.0134 168 B 1.50

1ns = 1/1,000,000,000 seconds

It is comparing the serialization performance between these two models:

public class PrimitiveModel
{
    public int Age { get; set; } = 42;
    public string FirstName { get; set; } = "John";
    public string? LastName { get; set; } = null;
}

public class OptionalValueModel
{
    public OptionalValue<int> Age { get; set; } = 42;
    public OptionalValue<string> FirstName { get; set; } = "John";
    public OptionalValue<string> LastName { get; set; } = default;
}
Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 is compatible.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last updated
0.4.12 76 1/15/2025
0.4.2 74 1/10/2025
0.3.4 82 1/7/2025
0.2.3 101 12/19/2024
0.2.1 89 12/11/2024