Serializing audit information with System.Text.Json
/ 14 min read
Introduction
I recently overhauled the job processing engine in Cloudpress to use JSON documents for configuring the export jobs (more on that in a future blog post). One of the things I wanted to add was writing audit information when serializing the content to JSON.
I initially tried to do this with a custom JSON serializer but ran into issues that made it more complex than necessary. Specifically, I ran into issues with recursion as described here and elsewhere.
After taking a step back and looking at the documentation, I came across the section about customizing the JSON contract. The following section from the documentation describes this better:
The System.Text.Json library constructs a JSON contract for each .NET type, which defines how the type should be serialized and deserialized
[…]
Starting in .NET 7, you can customize these JSON contracts to provide more control over how types are converted into JSON and vice versa.
Creating a basic modifier
To understand how the concept works, let’s start off with a basic modifier to add additional auditing properties when serializing an object.
In the code above, I created custom JSON serialization options with a custom modifier attached to DefaultJsonTypeInfoResolver
. The modifier first checks to see that we are serializing an object; if not, it aborts. After that, we create two JsonPropertyInfo
instances for the two auditing fields with custom getters that set their values (the user and date).
Running this code produces the following output:
This is a good start, but there is room for improvement. Let’s see how to build on this to create something more flexible.
Applying the naming policy
You’ll notice that in the serialized JSON document, the properties from the Person
record are converted to camel case - due to the PropertyNamingPolicy
policy specified in the JsonSerializerOptions
instance. Our two auditing properties are, however, Pascal case.
The naming policy is applied internally by the DefaultJsonTypeInfoResolver
, so if we want our auditing properties to conform to it, we’ll need to apply it before we add them to the JsonTypeInfo
passed into the modifier.
We can simply name these properties createdBy
and createdAt
, but that means that if we ever decide on a new naming policy, these two properties will remain in camel case.
We can access the property naming policy from inside our modifier. Let’s update our code by adding a new ApplyPropertyNamingPolicy
method to apply the naming policy and update our code to use this new method.
With this in place, the naming policy is applied to our audit properties.
Excluding certain classes from auditing
Let’s add a new record called Company
and update the Person
record to reference it.
Let’s also update the object we are serializing to pass in an instance of the company.
Executing the update code produces the following JSON document:
You can see that we are also appending the auditing properties to the company. Let’s assume we only want these properties attached to the Person
class.
We can fix this by adding a check for the object’s type and aborting if it is not a Person
instance.
Executing our application produces the following output:
The change gives us the desired result, but the fact that it is hard-coded to the Person
type makes it inflexible. We can update it to include other types in the future, but it would be more flexible if we could add an indicator to identify the types we want to be audited.
The most common approach to this is to add a marker interface or attribute that can be applied to types we want to be audited. I am a fan of the latter, but I’ll quickly show how to use a marker interface to achieve the same result.
Using a marker interface
To use a marker interface, declare an interface named IAuditable
and ensure that the types you want to be audited implement it. Below, you can see the declaration of this interface and its implementation in the Person
record.
The code that previously checked explicitly for the Person
type can now be updated to check whether the type implements the IAuditable
interface.
When executing this code, you will get the same result, with the auditing attributes added only to the Person
objects.
Using a marker attribute
Since many aspects of JSON serialization can be controlled by attributes, I am a fan of this approach. Let’s create a marker attribute named JsonAuditableAttribute
.
We also need to apply this attribute to the Person
record.
Finally, we can update the code for the modifier to add the audit attributes only to types with the [JsonAuditable]
attribute applied.
This will give us the same result but is a more flexible solution as we can add the [JsonAuditable]
attribute to any class we want to be audited.
Configuring JsonOptions via DI
So far, we have created a new JsonSerializerOptions
instance every time we serialize the objects to JSON. In a typical application, the JsonSerializerOptions
will likely be configured with Dependency Injection.
For this example, I created an ASP.NET Core Minimal API project. We have API endpoints returning JSON payloads, and we want the auditing applied to them.
JSON serialization can be configured for Minimal API projects using the ConfigureHttpJsonOptions
extension method. We can configure the JSON serializer options and plug our auditing modifier into the serialization pipeline.
Calling the /people
endpoint will return the list of people and apply the auditing attributes.
Injecting services into JsonOptions
Until now, the createdBy
auditing property has hard-coded. In a real-world system, we must access the HttpContext
to obtain the user.
For this example, I created a Minimal API project and added authentication via JSON Web Tokens. I’ll use the dotnet user-jwts
tool to generate a JWT for local testing and demonstration purposes.
We will want to access the HttpContext
to obtain the user, so we must inject the IHttpContextAccessor
with the DI container (see the docs for more info).
Since we want to access IHttpContextAccessor
from the DI container while configuring the JSON serialization options, we will create a class that inherits from IConfigureOptions<TOptions>
and register that with the DI container (once again, see the docs for more info).
You will see that the code for configuring the JSON serialization options is similar to before, but this time, we are using the HttpContext
to obtain the current user’s name.
Next, we need to configure the options with the DI container.
With this in place, when we call the /people
endpoint, the auditing attributes are applied using the username of the user making the request.
Downsides
Overall, I find this to be quite an elegant method of modifying the serialization contract without resorting to writing custom serializers. However, it is not without downsides.
One downside I found was that you cannot alter the contract on a per-instance basis. For example, I showed you how to add the auditing attributes only to certain types using a marker attribute. However, it will add them to all instances of that type.
In the examples above, I opted the Person
type into the auditing behaviour, so all instances of Person
will have those attributes.
One way you may work around this on a per-instance basis is to return null
values from the getters and then set the DefaultIgnoreCondition
to JsonIgnoreCondition.WhenWritingNull
.
Since we are returning null
for the auditing properties of “Jerrie”, those properties will not be serialized.
Conclusion
In this blog post, I showed how to customise the JSON serialization contract using the DefaultJsonTypeInfoResolver
and adding modifiers. I demonstrated this by using an example of adding auditing properties to the serialized JSON documents.
I showed how you can control this behaviour by adding a marker attribute. Finally, I showed how you can add the JSON serialization options to the DI container and inject other services into our JSON serialization options.
The code for the sample projects I created for this blog post can be found at https://github.com/jerriepelser-blog/system-text-json-write-audit-information.