Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Is there a way to handle asp.net core odata errors

Is there a way to handle asp.net core odata errors?

I have a model class DimDateAvailable with one property, a primary key of int DateId, and I make a call like /data/DimDateAvailable?$select=test.

Other calls work as expected and return what I'm after - this is a deliberate call to generate a fault, and it fails because there is no property named test on the model. The response comes back as expected, like so: {"error":{"code":"","message":"The query specified in the URI is not valid. Could not find a property named 'test' on type 'DimDateAvailable'... followed by a stack trace.

This response is fine when env.IsDevelopment() is true but I don't want to expose the stack trace when not in development.

I've looked at wrapping the code in the controllers' get method in a try-catch, but I think there's an action filter running over the results so it never gets called. On the other hand, I can't see where to inject any middleware and/or add any filters to catch errors. I suspect there might be a way to override an output formatter to achieve what I want but I can't see how.

Here's what I have at the moment:

In Startup.cs:

public void ConfigureServices(IServiceCollection services)
{
  services.AddScoped<TelemetryDbContext>();
  services.AddOData();
  services.AddMvc();
}

public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
  if (env.IsDevelopment())
  {
    app.UseDeveloperExceptionPage();
  }

  app.UseMvc(routeBuilder =>
  {
    routeBuilder.MapODataServiceRoute("odata", "data", GetEdmModel());
    routeBuilder.Select().Expand().Filter().OrderBy().MaxTop(null).Count();

    // insert special bits for e.g. custom MLE here
    routeBuilder.EnableDependencyInjection();
  });
}

private static IEdmModel GetEdmModel()
{
  var builder = new ODataConventionModelBuilder();
  builder.EntitySet<DimDateAvailable>("DimDateAvailable");
  return builder.GetEdmModel();
}

In TelemetryDbContext.cs:

public virtual DbSet<DimDateAvailable> DimDateAvailable { get; set; }

In DimDateAvailable.cs

public class DimDateAvailable
{
  [Key]
  public int DateId { get; set; }
}

My controller:

public class DimDateAvailableController : ODataController
{
  private readonly TelemetryDbContext data;

  public DimDateAvailableController(TelemetryDbContext data)
  {
    this.data = data;
  }

  [EnableQuery(AllowedQueryOptions = AllowedQueryOptions.Supported, PageSize = 2000)]
  public IActionResult Get()
  {
    return Ok(this.data.DimDateAvailable.AsQueryable());
  }
}

This is in an asp.net core 2 web app with the Microsoft.AspNetCoreOData v7.0.1 and EntityFramework 6.2.0 packages.

like image 573
meataxe Avatar asked Feb 04 '23 22:02

meataxe


2 Answers

Investigating Ihar's suggestion lead me down the rabbit hole, and I ended up inserting an ODataOutputFormatter into the MVC options to intercept ODataPayloadKind.Error responses and reformat them.

It was interesting to see that context.Features held an instance of IExceptionHandlerFeature in app.UseExceptionHandler() but not in the ODataOutputFormatter. That lack was pretty much what prompted me to pose this question in the first place, but was solved by translating the context.Object in the ODataOutputFormatter which is something I saw done in the OData source as well. I don't know if the changes below are good practice in asp.net core or when using the AspNetCoreOData package, but they do what I want for now.

Changes to Startup.cs

public void ConfigureServices(IServiceCollection services)
{
  services.AddScoped<TelemetryDbContext>();
  services.AddOData();
  services.AddMvc(options =>
  {
    options.OutputFormatters.Insert(0, new CustomODataOutputFormatter(this.Environment.IsDevelopment()));   
  });
}

public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
  if (env.IsDevelopment())
  {
    app.UseDeveloperExceptionPage();
  }

  // Added this to catch errors in my own code and return them to the client as ODataErrors
  app.UseExceptionHandler(appBuilder =>
  {
    appBuilder.Use(async (context, next) =>
    {
      var error = context.Features[typeof(IExceptionHandlerFeature)] as IExceptionHandlerFeature;
      if (error?.Error != null)
      {
        context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
        context.Response.ContentType = "application/json";

        var response = error.Error.CreateODataError(!env.IsDevelopment());
        await context.Response.WriteAsync(JsonConvert.SerializeObject(response));
      }

      // when no error, do next.
      else await next();
    });
  });

  app.UseMvc(routeBuilder =>
  {
    routeBuilder.MapODataServiceRoute("odata", "data", GetEdmModel());
    routeBuilder.Select().Expand().Filter().OrderBy().MaxTop(null).Count();

    // insert special bits for e.g. custom MLE here
    routeBuilder.EnableDependencyInjection();
  });
}

New classes CustomODataOutputFormatter.cs and CommonExtensions.cs

public class CustomODataOutputFormatter : ODataOutputFormatter
{
  private readonly JsonSerializer serializer;
  private readonly bool isDevelopment;

  public CustomODataOutputFormatter(bool isDevelopment) 
    : base(new[] { ODataPayloadKind.Error })
  {
    this.serializer = new JsonSerializer { ContractResolver = new CamelCasePropertyNamesContractResolver() };
    this.isDevelopment = isDevelopment;

    this.SupportedMediaTypes.Add("application/json");
    this.SupportedEncodings.Add(new UTF8Encoding());
  }

  public override Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding)
  {
    if (!(context.Object is SerializableError serializableError))
    {
      return base.WriteResponseBodyAsync(context, selectedEncoding);
    }

    var error = serializableError.CreateODataError(this.isDevelopment);        
    using (var writer = new StreamWriter(context.HttpContext.Response.Body))
    {
      this.serializer.Serialize(writer, error);
      return writer.FlushAsync();
    }
  }    
}

public static class CommonExtensions
{
  public const string DefaultODataErrorMessage = "A server error occurred.";

  public static ODataError CreateODataError(this SerializableError serializableError, bool isDevelopment)
  {
    // ReSharper disable once InvokeAsExtensionMethod
    var convertedError = SerializableErrorExtensions.CreateODataError(serializableError);
    var error = new ODataError();
    if (isDevelopment)
    {
      error = convertedError;
    }
    else
    {
      // Sanitise the exposed data when in release mode.
      // We do not want to give the public access to stack traces, etc!
      error.Message = DefaultODataErrorMessage;
      error.Details = new[] { new ODataErrorDetail { Message = convertedError.Message } };
    }

    return error;
  }

  public static ODataError CreateODataError(this Exception ex, bool isDevelopment)
  {
    var error = new ODataError();

    if (isDevelopment)
    {
      error.Message = ex.Message;
      error.InnerError = new ODataInnerError(ex);
    }
    else
    {
      error.Message = DefaultODataErrorMessage;
      error.Details = new[] { new ODataErrorDetail { Message = ex.Message } };
    }

    return error;
  }
}

Changes to the controller:

[EnableQuery(AllowedQueryOptions = AllowedQueryOptions.Supported, PageSize = 2000)]
public IQueryable<DimDateAvailable> Get()
{
  return this.data.DimDateAvailable.AsQueryable();
}
like image 193
meataxe Avatar answered Mar 16 '23 23:03

meataxe


If you want a customization of responses, including customization of error responses try to use ODataQueryOptions instead of using

[EnableQuery(AllowedQueryOptions = AllowedQueryOptions.Supported, PageSize = 2000)]

Check some samples at https://learn.microsoft.com/en-us/aspnet/web-api/overview/odata-support-in-aspnet-web-api/supporting-odata-query-options#invoking-query-options-directly

It would allow you to cache validation errors and build custom response.

like image 26
Ihar Yakimush Avatar answered Mar 17 '23 00:03

Ihar Yakimush