Skip to content

JSON serialization

Hardened serializes JSON with System.Text.Json. A source-generated JsonSerializerContext registered as an IJsonTypeInfoResolver is read by every JSON serializer in the pipeline: the request body, the response body, and each item of a streamed response.

csharp
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web)]
[JsonSerializable(typeof(Todo))]
[JsonSerializable(typeof(NewTodo))]
public partial class AppJsonContext : JsonSerializerContext;

public record Todo(int Id, string Title, Priority Priority);

public enum Priority { Low, InProgress }
json
{ "id": 1, "title": "Read the generated code", "priority": "inProgress" }

The property names are camelCase from JsonSerializerDefaults.Web. The enum went on the wire as inProgress with no converter written, because the build writes one per enum.

Registering the context

csharp
[HardenedModule]
[HardenedWebModule]
public partial class AppLibrary : IServiceCollectionConfiguration {

    public void ConfigureServices(IServiceCollection services) {
        services.AddSingleton<IJsonTypeInfoResolver>(AppJsonContext.Default);
    }
}

Registered resolvers answer first, in registration order. Reflection covers whatever they do not declare, on a host where reflection is available, so a context does not have to be complete to be worth registering. The project templates ship this wiring.

Enum vocabulary

[JsonEnumNaming] decides an enum's wire values, and it governs the published document as well as the wire. The build writes a converter per enum, registers it for the JSON body and for the parameter binder, and writes the same values into the enum array of the generated OpenAPI description. The default is camelCase. To choose something else, set it for the assembly, for one enum, or both, and the enum wins:

csharp
[assembly: JsonEnumNaming(EnumNaming.KebabCaseLower)]   // "low", "in-progress"

[JsonEnumNaming(EnumNaming.MemberName)]                  // opts out: "AB12", "CD34"
public enum LegacyCode { AB12, CD34 }

EnumNaming offers MemberName, CamelCase, KebabCaseLower, SnakeCaseLower and SnakeCaseUpper. The assembly-level attribute goes on the assembly, not the module class; AttributeTargets makes a misplaced one a compile error.

Parameters bind through the same vocabulary, so ?priority=in-progress binds in an application whose body accepts exactly that.

Decide the vocabulary before the first client

Changing an enum's wire values later breaks every consumer, and no compiler will tell you it happened.

Three things are left alone. A [Flags] enum, because a flags value is a combination of members rather than one of them. An enum from a referenced library, which opts in by that library declaring [assembly: JsonEnumNaming] of its own. And aliased members, where two members sharing a value keep the first declared, which is the one Enum.ToString picks.

Adding your own converter

Five routes, and they do not all reach the same code:

RouteReachesUse when
[JsonEnumNaming]Body, parameters and the documentAny enum
[JsonConverter] on the type or propertyEverythingA non-enum type with one wire form
A JsonSerializerContext registered as IJsonTypeInfoResolverEverythingThe general answer. AOT-safe, and how the templates do it
Converters on [JsonSourceGenerationOptions]Everything, via that contextA converter that should apply across the context's types
Hardened.Requests.Runtime.Configuration.JsonSerializerConfigurationThe wire onlyYou need to replace JsonSerializerOptions wholesale

The last one takes the whole options object rather than a delta, so setting it drops JsonSerializerDefaults.Web, camelCase, case-insensitive matching and AllowReadingFromString included, unless you rebuild all of it. Set both SerializeOptions and DeSerializerOptions or the application writes what it will not read.

A converter in Options.Converters outranks [JsonConverter] on the type

System.Text.Json ranks options-level converters above the attribute. Adding a bare JsonStringEnumConverter to the options therefore overrides converters generated from an OpenAPI or Smithy contract, and writes the C# member name where the document declares something else: "ScienceFiction" for a schema whose value is science-fiction, and a 400 reading it back. Attach converters to types, or scope them to a context.

Contract-first applications

If your models come from an OpenAPI description or a Smithy model, [JsonEnumNaming] does not apply and is not needed. Those enum values come from the description, and the build already emits a converter carrying them plus the resolver that registers it.

Native AOT

A published AOT application has no reflection fallback. Every type on the wire must be declared in a registered context, and one that is missing throws a NotSupportedException naming it.

Do not use JsonStringEnumConverter in a Hardened application. It writes the C# member name rather than a wire value and never reaches the published document; [JsonEnumNaming] is the supported route. If you reach for it anyway, the non-generic form builds a converter per enum at run time, which AOT cannot do:

csharp
[JsonConverter(typeof(JsonStringEnumConverter))]        // works on a JIT host, fails when published
[JsonConverter(typeof(JsonStringEnumConverter<Priority>))]  // correct

The compiler reports this as SYSLIB1034 wherever the enum is reachable from a JsonSerializerContext, including when the non-generic form appears in Converters on [JsonSourceGenerationOptions]. It is a warning, so TreatWarningsAsErrors is what makes it stop a build. An enum reachable from no context gets no diagnostic at all. It works locally and fails after publishing.

UseStringEnumConverter is AOT-safe but writes the C# member name and does not reach the document, so an application using it publishes a description its own wire format disagrees with. Leave it off and let the build write the converters.

Next

Released under the MIT License.