AspNetCoreOData icon indicating copy to clipboard operation
AspNetCoreOData copied to clipboard

Value cannot be null or empty. (Parameter 'entitySetName')

Open michelbieleveld opened this issue 2 years ago • 2 comments

ASP.NET Core OData 8.2.3

I am trying to do a post to http://localhost:5107/v1/users('michel')/calendarGroups which works without Accept: application/json;odata.metadata=full , but with I get the error

 Value cannot be null or empty. (Parameter 'entitySetName')

see screenshot. It seems Identifier in this case is empty, I am not sure why. It does seems to work even with full when I rewrite the url to /v1/ instead of v1/.

What am I doing wrong ? I think I have followed most standard documentation. ( If you see errors in naming convention, I have temporarily disabled my camelcase converter to make sure that wasn't the problem, haven't updated all urls in code).

identity missing

I am trying to use attribute routing and have this as my program.cs

using My.Booking.API;
using My.Booking.Domain.Models;
using My.Booking.Persistence;
using Microsoft.AspNetCore.OData.Batch;
using Microsoft.OData.ModelBuilder;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.OData;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

var edmBuilder = new ODataConventionModelBuilder
{
    Namespace = "graph"
};

edmBuilder.EntitySet<Entity>("entities");
edmBuilder.EntitySet<OutlookItem>("outlookItem");
edmBuilder.EntitySet<Calendar>("calendars");
edmBuilder.EntitySet<CalendarGroup>("calendarGroups");
edmBuilder.EntitySet<Event>("events");
var users = edmBuilder.EntitySet<User>("users");
users.HasRequiredBinding(p => p.Calendar,"Calendar");
edmBuilder.Singleton<Me>("me");
edmBuilder.EnableMyLowerCamelCase();
var edmModel = edmBuilder.GetEdmModel();

builder.Services.AddSingleton<IConfiguration>(builder.Configuration);
builder.Services.AddCors(options =>
{
    options.AddDefaultPolicy(policy  =>
    {
        policy
            .AllowAnyOrigin()
            .AllowAnyHeader()
            .AllowAnyMethod();
        //policy.WithHeaders("X-API-KEY", "Origin", "X-Requested-With", "Content-Type", "Accept", "Access-Control-Request-Method","prefer","Access-Control-Allow-Origin");
        //policy.WithMethods("GET", "POST", "OPTIONS", "PUT", "DELETE");
    });
});
builder.Services.AddDbContext<BookingDbContext>(opt => opt.UseSqlServer());
builder.Services.AddControllers().AddOData(options =>
{
    options.RouteOptions.EnablePropertyNameCaseInsensitive = true;
    options.RouteOptions.EnableControllerNameCaseInsensitive = true;
    options.RouteOptions.EnableKeyAsSegment = true;
    options.RouteOptions.EnableKeyInParenthesis = true;
    options.Select().Filter().Count().OrderBy().Expand()
        .AddRouteComponents("v1", edmModel, opts =>
        {
            opts.AddSingleton<ODataBatchHandler,DefaultODataBatchHandler>();
        });
    options.TimeZone = TimeZoneInfo.Utc;
});

var app = builder.Build();
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
    ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
});
app.UseCors();
app.UseODataRouteDebug();
app.UseODataBatching();
app.UseRouting();
app.UseEndpoints(endpoints => endpoints.MapControllers());
app.UseHttpsRedirection();
await app.ResetDatabaseAsync();
app.Run();

This is my UsersController.cs

using My.Booking.Domain.Models;
using My.Booking.Persistence;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.OData.Deltas;
using Microsoft.AspNetCore.OData.Query;
using Microsoft.AspNetCore.OData.Routing.Controllers;
using Microsoft.EntityFrameworkCore;

namespace My.Booking.API.Controllers;

public class UsersController(BookingDbContext dbContext) : ODataController
{
    [EnableQuery]
    public ActionResult<IQueryable<User>> Get()
    {
        return Ok(dbContext.Users);
    }
    
    public ActionResult<User> Get([FromRoute] string key)
    {
        
        var user = dbContext.Users.SingleOrDefault(d => d.Id == key);

        if (user == null)
        {
            return NotFound();
        }

        return Ok(user);
    }
    
    public ActionResult Post([FromBody] User user)
    {
        dbContext.Users.Add(user);
        dbContext.SaveChanges();

        return Created(user);
    }
    
    public ActionResult Put([FromRoute] string key, [FromBody] User updatedUser)
    {
        var user = dbContext.Users.SingleOrDefault(d => d.Id == key);

        if (user == null)
        {
            return NotFound();
        }

        // customer.Name = updatedCustomer.Name;
        // customer.CustomerType = updatedCustomer.CustomerType;
        // customer.CreditLimit = updatedCustomer.CreditLimit;
        // customer.CustomerSince = updatedCustomer.CustomerSince;

        dbContext.SaveChanges();

        return Updated(user);
    }
    
    public ActionResult Patch([FromRoute] string key, [FromBody] Delta<User> delta)
    {
        var user = dbContext.Users.SingleOrDefault(d => d.Id == key);

        if (user == null)
        {
            return NotFound();
        }

        delta.Patch(user);

        dbContext.SaveChanges();

        return Updated(user);
    }
    
    public ActionResult Delete([FromRoute] string key)
    {
        var user = dbContext.Users.SingleOrDefault(d => d.Id == key);

        if (user != null)
        {
            dbContext.Users.Remove(user);
        }

        dbContext.SaveChanges();

        return NoContent();
    }
    
    [HttpGet("v1/Users({key})/CalendarGroups")]
    [HttpGet("v1/Users/{key}/CalendarGroups")]
    [EnableQuery]
    public IQueryable<CalendarGroup> GetCalendarGroups([FromRoute] string key)
    {
        Microsoft.OData.Evaluation.ODataConventionalUriBuilder.BuildEntitySetUri(
        return (from cgu in dbContext.CalendarGroupUsers
            join cg in dbContext.CalendarGroups on cgu.CalendarGroupsId equals cg.Id
            where cgu.UserId == key 
            select cg).AsNoTracking();
    }
    
    
    [HttpGet("v1/users({key})/calendarGroups({relatedKey})")]
    [HttpGet("v1/users/{key}/calendarGroups/{relatedKey}")]
    [EnableQuery]
    public IQueryable<CalendarGroup> GetCalendarGroups([FromRoute] string key,[FromRoute] string relatedKey)
    {
        return (from cgu in dbContext.CalendarGroupUsers
            join cg in dbContext.CalendarGroups on cgu.CalendarGroupsId equals cg.Id
            where cgu.UserId == key && cg.Id == relatedKey
            select cg).AsNoTracking();
    }
    

    
    [HttpPost("v1/Users({key})/CalendarGroups")]
    [HttpPost("v1/Users/{key}/CalendarGroups")]
    public IActionResult PostToCalendarGroups([FromRoute] string key, [FromBody] CalendarGroup calendarGroup)
    {
        var user = dbContext.Users.SingleOrDefault(d => d.Id == key);

        if (user == null)
        {
            return NotFound();
        }
        dbContext.CalendarGroups.Add(calendarGroup);
        user.CalendarGroups.Add(calendarGroup);

        dbContext.SaveChanges();

        return Created(calendarGroup);
    }

    [HttpGet("v1/users({key})/calendar")]
    [HttpGet("v1/users/{key}/calendar")]
    public IQueryable<Calendar> GetCalendar([FromRoute] string key)
    {
        return (from cgu in dbContext.CalendarGroupUsers
                join cg in dbContext.CalendarGroups on cgu.CalendarGroupsId equals cg.Id
                join c in dbContext.Calendars on cg.Id equals EF.Property<string>(c, "CalendarGroupId")
                where c.IsDefaultCalendar == true && cgu.Default == true && cgu.UserId == key
                select c)
            .AsNoTracking();
    }
    
    [HttpGet("v1/users({key})/calendarGroups({relatedKey})/calendars")]
    [HttpGet("v1/users/{key}/calendarGroups/{relatedKey}/calendars")]
    public IQueryable<Calendar> GetCalendars([FromRoute] string key,[FromRoute] string relatedKey)
    {
        return (from cgu in dbContext.CalendarGroupUsers
                join cg in dbContext.CalendarGroups on cgu.CalendarGroupsId equals cg.Id
                join c in dbContext.Calendars on cg.Id equals EF.Property<string>(c, "CalendarGroupId")
                where  cgu.UserId == key && cg.Id == relatedKey
                select c)
            .AsNoTracking();
    }
    
    [HttpPost("v1/users({key})/calendarGroups({relatedKey})/calendars")]
    [HttpPost("v1/users/{key}/calendarGroups/{relatedKey}/calendars")]
    public IActionResult PostToCalendarGroups([FromRoute] string key, [FromRoute] string relatedKey, [FromBody] Calendar calendar)
    {
        var user = dbContext.Users.SingleOrDefault(d => d.Id == key);

        if (user == null)
        {
            return NotFound();
        }
        
        var t = (from cgu in dbContext.CalendarGroupUsers
            join cg in dbContext.CalendarGroups on cgu.CalendarGroupsId equals cg.Id
            where cgu.UserId == key && cg.Id == relatedKey
            select cg).SingleOrDefault();
        
        if (t == null)
        {
            return NotFound();
        }

        calendar.Owner = new EmailAddress(user.Mail,user.DisplayName);
        
        dbContext.Calendars.Add(calendar);
        t.Calendars.Add(calendar);
        dbContext.SaveChanges();

        return Created(calendar);
    }



}

michelbieleveld avatar Oct 31 '23 16:10 michelbieleveld

If I forget about conventional routing which I can't get to work, but fix the path like

  [HttpGet("/Users({key})/CalendarGroups")]
    [HttpGet("/Users/{key}/CalendarGroups")]
    [EnableQuery]
    public IQueryable<CalendarGroup> GetCalendarGroupsFromUser(string key)
    {
        //Microsoft.AspNetCore.OData.Routing.Conventions.NavigationRoutingConvention
        //Microsoft.AspNetCore.OData.Routing.ODataRoutingMatcherPolicy
        //Microsoft.AspNetCore.OData.Routing.ODataPathSegmentHandler
        //Microsoft.OData.Evaluation.ODataConventionalUriBuilder.BuildEntitySetUri(
        // return (from cgu in dbContext.CalendarGroupUsers
        //     join cg in dbContext.CalendarGroups on cgu.CalendarGroupsId equals cg.Id
        //     where cgu.UserId == key 
        //     select cg).AsNoTracking();

        var u = new User()
        {
            Id = "123",
            CalendarGroups = new List<CalendarGroup>()
            {
                new CalendarGroup()
                {
                    Id = "1",
                    Name = "a"
                }
            }
        };
        return u.CalendarGroups.AsQueryable();
    }

I get the following result in postman

{
    "@odata.context": "http://localhost:5107/$metadata#Users('michel')/CalendarGroups",
    "value": [
        {
            "@odata.type": "#graph.CalendarGroup",
            "@odata.id": "Users('michel')/CalendarGroups('1')",
            "@odata.editLink": "Users('michel')/CalendarGroups('1')",
            "Id": "1",
            "Name": "a",
            "ChangeKey": "",
            "[email protected]": "http://localhost:5107/Users('michel')/CalendarGroups('1')/Calendars/$ref",
            "[email protected]": "http://localhost:5107/Users('michel')/CalendarGroups('1')/Calendars"
        }
    ]
}

According to

4.5.7 Annotation odata.id The odata.id annotation contains the entity-id; see [OData-Protocol]. By convention the entity-id is identical to the canonical URL of the entity, as defined in [OData-URL]. The odata.id annotation MUST appear if odata.metadata=full is requested.

4.5.8 Annotation odata.editLink and odata.readLink The odata.editLink annotation contains the edit URL of the entity; see [OData-Protocol]. The odata.readLink annotation contains the read URL of the entity or collection; see [OData-Protocol]. The default value of both the edit URL and read URL is the entity's entity-id

Seems that both @odata.id and @odata.editLink also should include the serviceRoot. Maybe missing because of the problem above.

michelbieleveld avatar Nov 01 '23 01:11 michelbieleveld

@michelbieleveld It's fixed at ODL side at: https://github.com/OData/odata.net/pull/2775 But it hasn't released yet.

xuzhg avatar Nov 06 '23 19:11 xuzhg