Skip to content

Tweak configuration of JsonSerializerOptions #49875

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Aug 5, 2023

Conversation

captainsafia
Copy link
Member

@captainsafia captainsafia commented Aug 4, 2023

Fixes #49855.
Fixes #49849.

@@ -506,6 +506,7 @@ namespace Microsoft.AspNetCore.Http.Generated
{{GeneratedCodeAttribute}}
file static class GeneratedRouteBuilderExtensionsCore
{
private static readonly JsonOptions FallbackJsonOptions = new();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should try setting a resolver here, otherwise the singleton itself would be susceptible to races as multiple threads attempt to initialize it.

Copy link
Member

@eerhardt eerhardt Aug 4, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We shouldn't need to set the resolver here.

The JsonSerializerOptions and JsonTypeInfoResolver get lazy initialized in JsonOptions here:

TypeInfoResolver = JsonSerializer.IsReflectionEnabledByDefault ? CreateDefaultTypeResolver() : null
};
// Use a copy so the defaults are not modified.
/// <summary>
/// Gets the <see cref="JsonSerializerOptions"/>.
/// </summary>
public JsonSerializerOptions SerializerOptions { get; } = new JsonSerializerOptions(DefaultSerializerOptions);

when IsReflectionEnabledByDeafult=false, the Resolver may be null. We should fix the thread safety concerns all up (RDG, RDF, and MVC) with #49849.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would depend on how DefaultSerializerOptions is defined. If it contains a resolver it would get picked up by the copy constructor.

Apropos, does the singleton need to be JsonOptions here. Seems it might as well be a JsonSerializerOptions.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would depend on how DefaultSerializerOptions is defined. If it contains a resolver it would get picked up by the copy constructor.

DefaultSerializerOptions doesn't initialize a resolver AFAICT (it's just new JsonSerlizerOptions(JsonDefaults.Web) so I think being explicit here helps.

Apropos, does the singleton need to be JsonOptions here. Seems it might as well be a JsonSerializerOptions.

True.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would depend on how DefaultSerializerOptions is defined.

It is part of the above linked code. I missed a couple lines.

internal static readonly JsonSerializerOptions DefaultSerializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
// Web defaults don't use the relaxed JSON escaping encoder.
//
// Because these options are for producing content that is written directly to the request
// (and not embedded in an HTML page for example), we can use UnsafeRelaxedJsonEscaping.
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
// The JsonSerializerOptions.GetTypeInfo method is called directly and needs a defined resolver
// setting the default resolver (reflection-based) but the user can overwrite it directly or by modifying
// the TypeInfoResolverChain
TypeInfoResolver = JsonSerializer.IsReflectionEnabledByDefault ? CreateDefaultTypeResolver() : null
};
// Use a copy so the defaults are not modified.
/// <summary>
/// Gets the <see cref="JsonSerializerOptions"/>.
/// </summary>
public JsonSerializerOptions SerializerOptions { get; } = new JsonSerializerOptions(DefaultSerializerOptions);

Apropos, does the singleton need to be JsonOptions here. Seems it might as well be a JsonSerializerOptions.

It should be using the DefaultSerializerOptions that is in JsonOptions because this is where our default JsonSerializerOptions is defined. We shouldn't be defining a new one here in generated code.

Comment on lines 84 to 86
var jsonOptions = serviceProvider?.GetService<IOptions<JsonOptions>>()?.Value ?? FallbackJsonOptions;
var jsonSerializerOptions = jsonOptions.SerializerOptions;
jsonSerializerOptions.TypeInfoResolver ??= JsonTypeInfoResolver.Combine();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assuming the singleton is fully configured, couldn't this be written without any mutations as follows?

Suggested change
var jsonOptions = serviceProvider?.GetService<IOptions<JsonOptions>>()?.Value ?? FallbackJsonOptions;
var jsonSerializerOptions = jsonOptions.SerializerOptions;
jsonSerializerOptions.TypeInfoResolver ??= JsonTypeInfoResolver.Combine();
var jsonSerializerOptions = serviceProvider?.GetService<IOptions<JsonOptions>>()?.Value?.SerializerOptions;
if (jsonSerializerOptions is null or { TypeInfoResolver: null })
{
// FallbackJsonOptions configured with the empty resolver.
jsonSerializerOptions = FallbackJsonOptions.Value.SerializerOptions;
}

Copy link
Member

@eerhardt eerhardt Aug 4, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that JsonOptions FallbackJsonOptions = new(); does NOT have an empty resolver when JsonSerializer.IsReflectionEnabledByDefault == false.

TypeInfoResolver = JsonSerializer.IsReflectionEnabledByDefault ? CreateDefaultTypeResolver() : null

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That would be great...wouldn't we run the risk of overriding scenarios where the user set the serializer options without an explicit type info resolver but other properties set?

builder.Services.ConfigureHttpJsonOptions(o => o.SerializerOptions.AllowTrailingCommas = true);

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that JsonOptions FallbackJsonOptions = new(); does NOT have an empty resolver when JsonSerializer.IsReflectionEnabledByDefault == false.

Correct! One implication that this has for our tests is the fact that our RDG tests are implicitly using the reflection-based type resolver for scenarios where we don't define JsonOptions. I realized this after trying to initialize a JsonSerializerOptions from generated code and discovering the implications of not using the one defined in JsonOptions.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that JsonOptions FallbackJsonOptions = new(); does NOT have an empty resolver when JsonSerializer.IsReflectionEnabledByDefault == false.

That's true for JsonOptions FallbackJsonOptions = new();, but that's not necessarily true for serviceProvider?.GetService<IOptions<JsonOptions>>()?.Value.

Someone could call JsonOptions.SerializerOptions.TypeInfoResolver = null, but I see we've added a test for this. Now that we no longer assign to JsonOptions.SerializerOptions.TypeInfoResolver when its null like the code originally referenced in this comment, I think we're fine.

@captainsafia captainsafia requested a review from a team as a code owner August 4, 2023 20:51
Copy link
Member

@eerhardt eerhardt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This LGTM. I'm slightly nervous about changing the TypeInfoResolver to be non-null so late, but I think this is the best solution to this problem.

Note that this change also fixes #49849. That should be closed when this is merged.

@captainsafia captainsafia added area-mvc Includes: MVC, Actions and Controllers, Localization, CORS, most templates area-minimal Includes minimal APIs, endpoint filters, parameter binding, request delegate generator etc and removed area-runtime labels Aug 4, 2023
@captainsafia captainsafia merged commit cf12d96 into main Aug 5, 2023
@captainsafia captainsafia deleted the safia/rdg-jsontyperesolver branch August 5, 2023 17:09
@ghost ghost added this to the 8.0-rc1 milestone Aug 5, 2023
@@ -262,8 +262,8 @@ public void WriteResponseBodyAsync_Works_WhenTypeResolverIsNull()
var jsonOptions = new JsonOptions();
jsonOptions.JsonSerializerOptions.TypeInfoResolver = null;

var stjOutputFormatter = SystemTextJsonOutputFormatter.CreateFormatter(jsonOptions);
Assert.IsAssignableFrom<IJsonTypeInfoResolver>(stjOutputFormatter.SerializerOptions.TypeInfoResolver);
var exception = Assert.Throws<InvalidOperationException>(() => SystemTextJsonOutputFormatter.CreateFormatter(jsonOptions));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(nit) the test name should probably be updated since this scenario no longer "works".

// the TypeInfoResolverChain
TypeInfoResolver = JsonSerializer.IsReflectionEnabledByDefault ? CreateDefaultTypeResolver() : null
// the TypeInfoResolverChain. Use JsonTypeInfoResolver.Combine() to produce an empty TypeInfoResolver.
TypeInfoResolver = JsonSerializer.IsReflectionEnabledByDefault ? CreateDefaultTypeResolver() : JsonTypeInfoResolver.Combine()
Copy link
Member

@eiriktsarpalis eiriktsarpalis Aug 7, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When trying to serialize with PublishTrimmed == true this will throw a "No metadata for type 'Foo'" error message. Consider authoring a custom resolver that throws a desirable error message. You could also have STJ do it for you by setting the value to JsonSerializerOptions.Default.TypeInfoResolver (although that would require a suppresion).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area-minimal Includes minimal APIs, endpoint filters, parameter binding, request delegate generator etc area-mvc Includes: MVC, Actions and Controllers, Localization, CORS, most templates
Projects
None yet
4 participants