-1

In my HTTP controller using model binding, how can I determine if a request model property, is NULL because it was explicitly set to NULL by the API client, or is NULL because it was missing in the original HTTP request?

TLDR:

How can I differentiate/discern/(tell the difference) between NULL and UNDEFINED in HTTP requests bound to models using model binding, where both NULL and UNDEFINED become just NULL.

Michał Turczyn
41.1k18 gold badges57 silver badges87 bronze badges
asked Jul 29 at 12:49
2
  • Better question: why do you care? Commented Jul 29 at 14:58
  • @IanKemp-SOdeadbyAIgreed --- To avoid clobbering data in HTTP update requests. Commented Jul 29 at 15:15

2 Answers 2

1

This is just wrong design.

As can be read here

A PATCH request performs a partial update to an existing resource. The client specifies the URI for the resource. The request body specifies a set of changes to apply to the resource.

On the other side, PUT request should actually contain all fields that are fetched from API and are used to update the entire entity.

To summarize, it would be best to align with RFC: PATCH Method for HTTP, and as recommended for example here , it should be array of operations:

[
 { "op": "replace", "path": "property1", "value": "NewValue"},
 { "op": "replace", "path": "property2", "value": 10}
]

Implementation:

JSON Patch support in ASP.NET Core web API

answered Jul 29 at 17:11
Sign up to request clarification or add additional context in comments.

8 Comments

With your example, the same problem applies to the value property, when value is NULL or UNDEFINED it becomes NULL when mapped to a strongly typed data structure, so how do you know whether the NULL was intentional? As for your PATCH/PUT concern, again, the same problem applies, in PUT the request should contain all fields, ok, but how can we make sure that it actually did? And in PATCH, a NULL property for example meaning to delete it from the database, how can we make sure it was intentional? Finally, I'm not using PATCH or PUT at all, only POST, oh no!
Even in a request to create a resource, knowing the difference is useful.
What does that mean " but how can we make sure that it actually did" ? You basically cannot make sure about anything about how the actual request was created. It is client responsibility and should be done there.
Rule #1 - never trust the client
That's why you validate your requests... Not try to handle whatever is sent.
|
-3

.NET Core MVC Web API [NULL vs UNDEFINED]

For - <TargetFramework>net7.0</TargetFramework> or newer.

Step 1 of 3

// https://stackoverflow.com/a/79718639
using System.Linq.Expressions;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Formatters;
namespace XXX.XXX;
public class JsonKeys<T> where T : class
{
 private string[] _jsonKeys = Array.Empty<string>();
 public bool HasKey<TReturn>(Expression<Func<T, TReturn>> expression) =>
 expression.Body is MemberExpression { Member.MemberType: MemberTypes.Property } memberExpression &&
 _jsonKeys.Contains(memberExpression.Member.Name, StringComparer.InvariantCultureIgnoreCase);
 public void SetJsonKeys(string[] jsonKeys)
 {
 if (_jsonKeys.Length > 0) throw new InvalidOperationException($"{nameof(jsonKeys)} is already set");
 _jsonKeys = jsonKeys;
 }
}
public class JsonKeysInputFormatter : SystemTextJsonInputFormatter
{
 public JsonKeysInputFormatter(
 JsonOptions options,
 ILogger<SystemTextJsonInputFormatter> logger
 ) : base(options, logger)
 {
 }
 public override async Task<InputFormatterResult> ReadRequestBodyAsync(InputFormatterContext context)
 {
 var httpContext = context.HttpContext;
 var memoryStream = new MemoryStream();
 await httpContext.Request.Body.CopyToAsync(memoryStream);
 memoryStream.Seek(0, SeekOrigin.Begin);
 httpContext.Request.Body = memoryStream;
 var inputFormatterResult = await base.ReadRequestBodyAsync(context);
 memoryStream.Seek(0, SeekOrigin.Begin);
 var jsonNode = await JsonSerializer.DeserializeAsync<JsonNode>(memoryStream, SerializerOptions);
 AddKeyExistence(inputFormatterResult.Model, jsonNode);
 await memoryStream.DisposeAsync();
 return inputFormatterResult;
 }
 private static void AddKeyExistence(object? model, JsonNode? jsonNode)
 {
 if (model == null || jsonNode == null || model.GetType().IsArray || !model.GetType().IsClass) return;
 // https://learn.microsoft.com/dotnet/fundamentals/reflection/how-to-examine-and-instantiate-generic-types-with-reflection
 var modelsGenericType = model.GetType().BaseType?.GetGenericArguments().SingleOrDefault();
 if (modelsGenericType == null) return;
 if (!model.GetType().IsAssignableTo(typeof(JsonKeys<>).MakeGenericType(modelsGenericType))) return;
 var setJsonKeysMethod = typeof(JsonKeys<>).GetMethod(nameof(JsonKeys<object>.SetJsonKeys));
 if (setJsonKeysMethod == null) return;
 var modelSetJsonKeysMethod = model.GetType().GetMethod(
 setJsonKeysMethod.Name,
 BindingFlags.Public | BindingFlags.Instance,
 setJsonKeysMethod.GetParameters().Select(a => a.ParameterType).ToArray()
 );
 if (modelSetJsonKeysMethod == null) return;
 JsonObject jsonObject;
 try
 {
 jsonObject = jsonNode.AsObject();
 }
 catch (Exception e)
 {
 Console.WriteLine(e);
 return;
 }
 var modelType = model.GetType();
 foreach (var propertyInfo in modelType.GetProperties())
 {
 if (propertyInfo.GetMethod == null) continue;
 var getValue = propertyInfo.GetMethod.Invoke(model, null);
 if (!jsonObject.TryGetPropertyValue(propertyInfo.Name, out var value)) continue;
 AddKeyExistence(getValue, value);
 }
 modelSetJsonKeysMethod.Invoke(model, new object[] { jsonObject.Select(a => a.Key).ToArray() });
 }
}

Step 2 of 3

// https://stackoverflow.com/a/79718639
using dotenv.net;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Formatters;
using Microsoft.Extensions.Options;
WebApplication? app = null;
var builder = WebApplication.CreateBuilder(args);
// ...
builder.Services.AddControllers(mvcOptions =>
{
 ArgumentNullException.ThrowIfNull(app);
 var loggerFactory = app.Services.GetRequiredService<ILoggerFactory>();
 var jsonOptions = app.Services.GetRequiredService<IOptions<JsonOptions>>();
 // https://learn.microsoft.com/aspnet/core/web-api/advanced/custom-formatters?view=aspnetcore-7.0
 mvcOptions.InputFormatters.Insert(0,
 new JsonKeysInputFormatter(jsonOptions.Value, loggerFactory.CreateLogger<SystemTextJsonInputFormatter>()));
});
// ...
app = builder.Build();

Step 3 of 3

// https://stackoverflow.com/a/79718639
using XXX.XXX;
using Microsoft.AspNetCore.Mvc;
namespace YYY.YYY.YYY;
public class Nested : JsonKeys<Nested>
{
 public string? NestedOne { get; set; }
}
public class MyRequest : JsonKeys<MyRequest>
{
 public string? One { get; set; }
 public string? Two { get; set; }
 public string? Three { get; set; }
 public Nested? Nested { get; set; }
 public int[]? Four { get; set; }
}
[ApiController]
[Route("api/dev")]
public class DevTestJsonKeysController : ControllerBase
{
 [HttpPost(nameof(TestJsonKeys))]
 public async Task<IActionResult> TestJsonKeys([FromBody] MyRequest request)
 {
 request.HasKey(x => x.Nested);
 request.Nested?.HasKey(x => x.NestedOne);
 return Ok();
 }
}
answered Jul 29 at 12:49

2 Comments

An answer must explain how it is solving the problem, not just be a dump of code. The latter are liable to be flagged as low-quality and deleted.
@IanKemp-SOdeadbyAIgreed --- I think it's obvious in step 3 how the problem is solved, I don't have the time to explain every line, I'm sure this post will be useful to someone in the future exactly as is.

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.