Open KwizaKaneria opened 2 years ago
@KwizaKaneria Is the expanded entity a contained
navigation property?
Kindly share a repro
Gentle ping @KwizaKaneria. Could you please share a repro or more detailed repro steps to help us understand the issue better?
Lisi's current PR/commit could be related. https://github.com/OData/AspNetCoreOData/pull/710. Attached the link for reference.
@KwizaKaneria Would you please share us a repo that can help us understand your problem and fix it as soon as possible.?
In particular, it's unclear from the description whether the nested nextlink is being generated but is invalid, or is not being generated, or exactly what the behavior may be.
Assuming the nested nextlink is being generated, if you could minimally share a request and a subset of the sample response showing the nested nextlink, and the response returned when calling the nested nextlink, that would be a great start.
It would also be helpful if you could share the $metadata (or relevant portion there-of).
Following are the code snippet: `
Code for State entity:
[DataContract] public class State { [Key] [DataMember(Name = "id")] public int Id { get; set; }
[DataMember(Name = "code")]
public string Code { get; set; }
[DataMember(Name = "name")]
public string Name { get; set; }
[Page(MaxTop = 100, PageSize = 100)]
[DataMember(Name = "counties")]
public virtual ICollection<CountyDetails> CountyDetails { get; set; } = new HashSet<CountyDetails>();
}
` 2. Code for County Entity:
[DataContract]
[Page(MaxTop = 100, PageSize = 100)]
public class CountyDetails
{
[Key]
[DataMember(Name ="id")]
public int Id { get; set; }
[DataMember(Name = "name")]
public string CountyName { get; set; }
public State State { get; set; }
public int StateId { get; set; }
}`
Configuration for State Entity:
public void Configure(EntityTypeBuilder
builder.Property(e => e.Id).HasColumnName("StateId");
builder.Property(e => e.Code).HasColumnName("State_Code");
builder.Property(e => e.Name).HasColumnName("State_Name");
}
Configuration for County Entity:
public void Configure(EntityTypeBuilder
builder.Property(e => e.CountyName).HasColumnName("County_Name");
builder.Property(e => e.Id).HasColumnName("County_CtySt_ID");
}`
@mikepizzo The next page link is generated but it is not working.
https://localhost:5001/odata/v1/states/17/counties?$skip=100
This is my next page link. It gives 404 in response.
@KwizaKaneria Please confirm whether counties
is a contained navigation property
@KwizaKaneria Do you have a controller method that handles /states/{id}/counties
?
@KwizaKaneria Please confirm whether you have implementing the relevant controller action in support of navigation property routing. In your case you'd need to implement a controller action named GetCounties
as follows:
[EnableQuery(PageSize = 10, MaxTop = 10)]
[HttpGet("{key}/counties")] // If you have a `Route` attribute at controller level, i.e., Route("YOURROUTEPREFIX/states"), or Route("states") if you have not configured a route prefix
// Or [HttpGet("YOURROUTEPREFIX/states/{key}/counties")]
// Or [HttpGet("states/{key}/counties")], i.e., If you haven't configured a route prefix
public ActionResult GetCounties([FromRoute] int key)
{
// ...
}
@KenitoInc @gathogojr We did not have a separate container for this.
@KwizaKaneria I don't understand your statement about separate container. Can you confirm whether you implemented the required controller method?
By "container" I assume you mean that you don't have a separate top-level collection ("entityset") that contains all of the countyDetails. That's what @gathogojr meant by "contained" -- the countyDetails are "contained" within each state.
In order to support directly retrieving countyDetails from a state, you'll have to implement the controller method on the state controller for retrieving the countyDetails for a particular state, as per @KenitoInc's comment and @gathogojr's example.
This controller method will support requesting counties for a specific state, for example:
/states/{stateId}/counties
Which is the resource path used for nested page links.
HTH!
@mikepizzo So are we need to register this route in the API gateway?
Hi @KwizaKaneria. Do you need further help with this? Did you try the suggested solutions?
@KwizaKaneria Did you manage to resolve your issue?
@KenitoInc No it is not resolved yet.
@gathogojr @mikepizzo How nextlink is generated differently? If my request isv1/states?$filter=id eq 55&$expand=counties
then nextpagelink should be same as v1/states?$filter=id eq 55&$expand=counties&$skip=100
. Why we need to create another controller method? another REST API?
@SViradiya-MarutiTech In your scenario, you wouldn't need to create another controller method.
i think i have the same problem.
EdmModel:
public static class EdmModelBuilder
{
internal static IEdmModel GetEdmModelv1()
{
var builder = new ODataConventionModelBuilder();
#region UserDto
var userDto = builder.EntitySet<UserDto>("Users").EntityType;
userDto.Name = "Users";
userDto.Function("Get")
.ReturnsFromEntitySet<UserDto>("Users")
.Parameter<int>("key").Required();
#endregion
#region PhotoDto
var photoDto = builder.EntitySet<PhotoDto>("Photos").EntityType;
photoDto.Name = "Photos";
photoDto.Function("Get")
.ReturnsFromEntitySet<PhotoDto>("Photos")
.Parameter<int>("key").Required();
#endregion
return builder.GetEdmModel();
}
}
UserDTO:
public record UserDto
{
public int Id { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public string UserName { get; set; }
[EmailAddress] public string Email { get; set; }
[Phone] public string PhoneNumber { get; set; }
[DataType(DataType.DateTime)] public DateTime BirthDateTime { get; set; }
[DataType(DataType.DateTime)] public DateTime CreatedDateTime { get; set; }
public char Gender { get; set; }
public virtual ICollection<Photo> Photos { get; set; }
}
PhotoDTO:
public record PhotoDto
{
public int Id { get; set; }
public Guid Guid { get; set; }
public string Name { get; set; }
public DateTime SaveDateTime { get; set; }
public string Size { get; set; }
public string SizeName { get; set; }
public int NumberOfShows { get; set; }
public UserDto User { get; set; }
}
UsersController:
public class UsersController : ODataController
{
#region ctor
private readonly ILogger<UsersController> _logger;
private readonly IMapper _mapper;
private readonly UserManager<AspNetUser> _userManager;
public UsersController(ILogger<UsersController> logger,
IMapper mapper,
UserManager<AspNetUser> userManager)
{
_mapper = mapper;
_userManager = userManager;
_logger = logger;
}
#endregion
[HttpGet, EnableQuery(PageSize = 10)]
public async Task<IActionResult> Get(ODataQueryOptions<UserDto> options)
{
var users = await _userManager.Users
.AsNoTracking()
.GetQueryAsync(_mapper, options);
return Ok(users);
}
[HttpGet, EnableQuery]
public async Task<IActionResult> Get(int key, ODataQueryOptions<UserDto> options)
{
var user = await _userManager.Users
.AsNoTracking()
.GetQuery(_mapper, options)
.SingleOrDefaultAsync(x => x.Id.Equals(key));
return Ok(user);
}
}
PhotosController:
public class PhotosController : ODataController
{
#region ctor
private readonly ILogger<PhotosController> _logger;
private readonly IWebHostEnvironment _webHostEnvironment;
private readonly IMapper _mapper;
private readonly DataListContext _dataListContext;
public PhotosController(ILogger<PhotosController> logger,
IWebHostEnvironment webHostEnvironment,
IMapper mapper,
DataListContext dataListContext)
{
this._logger = logger;
this._webHostEnvironment = webHostEnvironment;
this._mapper = mapper;
this._dataListContext = dataListContext;
}
#endregion
[HttpGet, EnableQuery(PageSize = 10)]
public async Task<IActionResult> Get(ODataQueryOptions<PhotoDto> options)
{
var photos = await _dataListContext.Photos
.AsNoTracking()
.GetQueryAsync(_mapper, options);
return Ok(photos);
}
[HttpGet, EnableQuery]
public async Task<IActionResult> Get(int key, ODataQueryOptions<PhotoDto> options)
{
var photo = await _dataListContext.Photos
.AsNoTracking()
.GetQuery(_mapper, options)
.SingleOrDefaultAsync(x => x.Id.Equals(key));
return Ok(photo);
}
}
User Model:
public partial class AspNetUser : IdentityUser<int>
{
[PersonalData] public string FirstName { get; set; }
[PersonalData] public string LastName { get; set; }
[PersonalData] public DateTime? BirthDateTime { get; set; }
public string Location { get; set; }
public DateTime CreatedDateTime { get; set; }
public bool IsActive { get; set; }
public Gender Gender { get; set; }
public virtual ICollection<Photo> Photos { get; set; }
}
Photo Model:
public class Photo
{
[Column(Order = 1)]
public int Id { get; set; }
[Column(Order = 2)]
public Guid? Guid { get; set; }
[Column(Order = 3)]
public int UserId { get; set; }
[Column(Order = 4)]
public string OrijinalName { get; set; }
[Column(Order = 5)]
public string ChangedName { get; set; }
[Column(Order = 6)]
public DateTime SaveDateTime { get; set; }
[Column(Order = 7)]
public string Size { get; set; }
[Column(Order = 8)]
public SizeName SizeName { get; set; }
[Column(Order = 9)]
public int NumberOfShows { get; set; }
[Column(Order = 10)]
public virtual AspNetUser AspNetUser { get; set; }
// fotoğraf çekilen bölgenin bilgileri varsa (location)
// kategori ile bölge harmanlanacak
}
When i send request to this url the result is successful. https://localhost:5001/api/users?$expand=Photos($orderby=Id%20desc)
but the suggested next link doesn't work.
What did you expect as the next link value @mansurdegirmenci ?
https://localhost:5001/api/Users?$expand=photos($skiptoken=Id-25) Shouldn't the url be like this? The next link already created by Odata is not working. @julealgon
Could you perhaps share the entire response payload for that example @mansurdegirmenci ?
Request:
https://localhost:5001/api/users?$expand=photos
{
"@odata.context": "https://localhost:5001/api/$metadata#Users(Photos())",
"value": [
{
"Id": 1,
"FirstName": "",
"LastName": "",
"UserName": "",
"Email": "",
"PhoneNumber": "",
"BirthDateTime": "",
"CreatedDateTime": "",
"Gender": "M",
"Photos": [
{
"Id": 16,
"Guid": "537405d1-99a0-453c-a0e7-b092b3611eb9",
"Name": "Art-PNG-HD-Image.png",
"SaveDateTime": "2023-04-25T00:16:57.1733333+03:00",
"Size": "724182",
"SizeName": "BYTE",
"NumberOfShows": 0
},
{
"Id": 17,
"Guid": "11536263-b738-4e9a-9f75-2169ec51f38d",
"Name": "gray02.png",
"SaveDateTime": "2023-04-27T21:55:36.41+03:00",
"Size": "1055",
"SizeName": "BYTE",
"NumberOfShows": 0
},
{
"Id": 18,
"Guid": "6b843cdf-81da-4405-a957-566201778677",
"Name": "20230101_191019.jpg",
"SaveDateTime": "2023-04-29T01:14:15.6833333+03:00",
"Size": "3363327",
"SizeName": "BYTE",
"NumberOfShows": 1
},
{
"Id": 19,
"Guid": "d8f5c238-b3d4-4619-9b0a-e5a6a2fadd9e",
"Name": "EISsy4VWkAUh6ur.jpeg",
"SaveDateTime": "2023-04-29T01:15:47.2333333+03:00",
"Size": "19796",
"SizeName": "BYTE",
"NumberOfShows": 1
},
{
"Id": 20,
"Guid": "8d7b3044-b98e-4343-829c-bc742cf197ab",
"Name": "Backend-.NET-Developer-Roadmap-2022.png",
"SaveDateTime": "2023-04-29T13:15:01.95+03:00",
"Size": "2002976",
"SizeName": "BYTE",
"NumberOfShows": 2
},
{
"Id": 21,
"Guid": "f111d3d2-df6f-42e2-a7f1-b74b5cb4e59b",
"Name": "gray02.png",
"SaveDateTime": "2023-05-06T00:39:02.65+03:00",
"Size": "1055",
"SizeName": "BYTE",
"NumberOfShows": 0
},
{
"Id": 22,
"Guid": "1b387c30-d331-49ae-a5ae-d3de61a190c7",
"Name": "delete.png",
"SaveDateTime": "2023-05-06T00:39:50.3066667+03:00",
"Size": "715",
"SizeName": "BYTE",
"NumberOfShows": 0
},
{
"Id": 23,
"Guid": "76e8f461-0913-4702-96f6-b61df9a10d2c",
"Name": "edit.png",
"SaveDateTime": "2023-05-06T00:46:53.4266667+03:00",
"Size": "450",
"SizeName": "BYTE",
"NumberOfShows": 0
},
{
"Id": 24,
"Guid": "8a8b876c-81f6-4987-8f6d-4c975a4eddeb",
"Name": "add.png",
"SaveDateTime": "2023-05-06T18:57:22.39+03:00",
"Size": "733",
"SizeName": "BYTE",
"NumberOfShows": 0
},
{
"Id": 25,
"Guid": "eee7b757-51f1-4cbc-b31f-047e442e15e0",
"Name": "20201231_143332.jpg",
"SaveDateTime": "2023-05-07T22:11:28.4866667+03:00",
"Size": "1923676",
"SizeName": "BYTE",
"NumberOfShows": 0
}
],
"Photos@odata.nextLink": "https://localhost:5001/api/Users/1/Photos?$skiptoken=Id-25"
}
]
}
Here it is. @julealgon
Thanks @mansurdegirmenci .
As I suspected, that next_page link is for the inner "photos" collection of the first user, and not for the external "users" one. Thus, it actually looks correct to me.
This is evident because it is called Photos@odata.nextLink
, and not just @odata.nextLink
which is the global one.
Are you sure you fully understand the idea behind server-driven paging? If you want the "users" to be paginated, then your response above would need over 10 user elements. What you have now is a single user entry, with over 10 photo child elements. Note that the skiptoken value is referring to the "Photo"s Id
and not to the "User"'s Id
there.
Thank you for your explanation, I understand what you are saying.. but I still think the generated nextlink is wrong. because it doesn't work. Could I have made a mistake in the "edm model" ?
but I still think the generated nextlink is wrong.
Do you have the necessary endpoint to handle the request though @mansurdegirmenci ? OData will generate the link assuming that you have that endpoint in place.
You need to provide an action to handle the users/{id}/photos
route for this to work as intended. You have a Photos controller but that won't be called by this particular route: what you need is a "photos" route in the users controller that finds the user, then returns it's photos.
Check the documentation on how to create the nested property endpoint if you are not aware of how it works.
@mansurdegirmenci Like @julealgon pointed out, you need to have an endpoint that is mapped to the /api/Users/{key}/Photos
route template. If you're using conventional routing and you have a UsersController
implemented:
api/Users
would be mapped to Get()
or GetUsers()
api/Users/{key}
would be mapped to Get(int key)
or GetUser(int key)
api/Users/{key}/Photos
would be mapped to GetPhotos(int key)
- this is the endpoint you're likely to be missingAlternatively, if you're using attribute routing place the route template on the HttpGet
attribute as follows:
[HttpGet("api/Users/{key}/Photos")]
public ActionResult<IEnumerable<Photo>> YourDesiredControllerActionName(int key)
{
// ...
}
@gathogojr Thank you for the explanatory information.
UsersController:
public class UsersController : ODataController
{
#region ctor
private readonly ILogger<UsersController> _logger;
private readonly IMapper _mapper;
private readonly UserManager<AspNetUser> _userManager;
private readonly DataListContext _dataListContext;
public UsersController(ILogger<UsersController> logger,
IMapper mapper,
UserManager<AspNetUser> userManager,
DataListContext dataListContext)
{
_mapper = mapper;
_userManager = userManager;
_logger = logger;
_dataListContext = dataListContext;
}
#endregion
[HttpGet, EnableQuery(PageSize = 10)]
public async Task<IActionResult> Get(ODataQueryOptions<UserDto> options)
{
var query = _userManager.Users.GetQuery(_mapper, options);
var result = await query.ToListAsync();
return Ok(result);
}
[HttpGet, EnableQuery]
public async Task<IActionResult> Get([FromODataUri] int key, ODataQueryOptions<UserDto> options)
{
var user = await _userManager.Users
.GetQuery(_mapper, options)
.SingleOrDefaultAsync(x => x.Id.Equals(key));
return Ok(user);
}
[HttpGet, EnableQuery(PageSize = 10)]
public async Task<IActionResult> GetPhotos([FromODataUri] int key, ODataQueryOptions<PhotoDto> options)
{
var query = _dataListContext.Photos
.Where(x => x.UserId == key)
.GetQuery(_mapper, options);
var result = await query.ToListAsync();
return Ok(result);
}
}
EdmModelBuilder:
public static class EdmModelBuilder
{
internal static IEdmModel GetEdmModelv1()
{
var builder = new ODataConventionModelBuilder();
#region UserDto
var userDto = builder.EntitySet<UserDto>("Users").EntityType;
userDto.Function("Get")
.ReturnsFromEntitySet<UserDto>("Users")
.Parameter<int>("key").Required();
userDto.Function("GetPhotos")
.ReturnsFromEntitySet<UserDto>("Users")
.Parameter<int>("key").Required();
#endregion
#region PhotoDto
var photoDto = builder.EntitySet<PhotoDto>("Photos").EntityType;
photoDto.Function("Get")
.ReturnsFromEntitySet<PhotoDto>("Photos")
.Parameter<int>("key").Required();
photoDto.Collection.Function("Download")
.Returns<IActionResult>()
.Parameter<int>("id").Required();
photoDto.Collection.Action("Upload")
.Returns<IActionResult>();
#endregion
return builder.GetEdmModel();
}
}
this is how i updated the settings. it works but I hope I spelled it right :) I shared it to be useful.
@mansurdegirmenci I'm glad to hear that it worked. You don't need to add the following functions:
userDto.Function("Get")
.ReturnsFromEntitySet<UserDto>("Users")
.Parameter<int>("key").Required();
userDto.Function("GetPhotos")
.ReturnsFromEntitySet<UserDto>("Users")
.Parameter<int>("key").Required();
photoDto.Function("Get")
.ReturnsFromEntitySet<PhotoDto>("Photos")
.Parameter<int>("key").Required();
The Get
and GetPhotos
controller actions in UsersController
together with the Get
controller action in PhotosController
are automatically and conventionally mapped to the respective route templates. Your service should work just fine after you remove the 3 statements in the code snippet above.
Download
function and Upload
action configurations you should retain.
The following would do the job:
public static class EdmModelBuilder
{
internal static IEdmModel GetEdmModelv1()
{
var builder = new ODataConventionModelBuilder();
#region UserDto
builder.EntitySet<UserDto>("Users");
#endregion
#region PhotoDto
var photoDto = builder.EntitySet<PhotoDto>("Photos").EntityType;
photoDto.Collection.Function("Download")
.Returns<IActionResult>()
.Parameter<int>("id").Required();
photoDto.Collection.Action("Upload")
.Returns<IActionResult>();
#endregion
return builder.GetEdmModel();
}
}
In your controller actions, I'd advise you not to use both EnableQuery
and ODataQueryOptions<T>
parameter. Use one or the other. By using them together, you're applying the same query options twice. The PageSize
you're setting from the EnableQuery
attribute can be set by passing a ODataQuerySettings
parameter to the ApplyTo
method of ODataQueryOptions
parameter.
Thanks for bringing up some good topics. AutoMapper.Extensions.OData mentions the same thing on github. I made a few edits, but of course there are some errors.
[HttpGet]
public async Task<IActionResult> Get(ODataQueryOptions<UserDto> options)
{
QuerySettings querySettings = new() { ODataSettings = new ODataSettings { PageSize = 10 } };
var result = await _userManager.Users.GetQueryAsync(_mapper, options, querySettings);
return Ok(result);
}
It's acting like I have 10 users. I have 1 user. Of course, when you go to the other created page, it comes blank, but I think it's a problem that it creates a nextlink.
brings new errors :) There was a problem requesting $count. Microsoft.OData.ODataException: The value of type 'Microsoft.EntityFrameworkCore.Query.Internal.EntityQueryable`1[[WebUI.Dto.Server.UserDto, WebUI, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null]]' could not be converted to a raw string.
that's all for now :)
Thank you for your knowledge and help. I have some questions with Odata, maybe you can help.
My first question is: Why are there methods marked as "default" in the "upload and download" methods I added? and how can I make my download method as download/16?
public class PhotosController : ODataController
{
#region ctor
private readonly ILogger<PhotosController> _logger;
private readonly IWebHostEnvironment _webHostEnvironment;
private readonly IMapper _mapper;
private readonly DataListContext _dataListContext;
public PhotosController(ILogger<PhotosController> logger,
IWebHostEnvironment webHostEnvironment,
IMapper mapper,
DataListContext dataListContext)
{
this._logger = logger;
this._webHostEnvironment = webHostEnvironment;
this._mapper = mapper;
this._dataListContext = dataListContext;
}
#endregion
[HttpGet]
public async Task<IActionResult> Get(ODataQueryOptions<PhotoDto> options) =>
Ok(await _dataListContext.Photos.GetQueryAsync(_mapper, options));
[HttpGet]
public async Task<IActionResult> Get([FromODataUri] int key, ODataQueryOptions<PhotoDto> options) =>
Ok(await _dataListContext.Photos.GetQuery(_mapper, options).SingleOrDefaultAsync(x => x.Id.Equals(key)));
[HttpGet]
public async Task<IActionResult> Download([FromODataUri] int id)
{
_logger.LogInformation("Running photos controller in download method");
var photo = await _dataListContext.Photos.FindAsync(id);
if (photo == null)
{
_logger.LogInformation("Photo is not found in list");
return NotFound("Photo is not found in list");
}
var path = Path.Combine(_webHostEnvironment.WebRootPath, "images", photo.OrijinalName);
if (!System.IO.File.Exists(path))
{
_logger.LogInformation("Photo file not found");
return NotFound("Photo file not found");
}
_logger.LogInformation("Photo is found");
return PhysicalFile(path, MediaTypeNames.Image.Jpeg);
}
[HttpPost]
public async Task<IActionResult> Upload()
{
_logger.LogInformation("Running photos controller in upload method");
if (Request.Form.Files.Count == 0)
{
return Conflict("No files were found in the request");
}
//var feature = HttpContext.Features.Get<IAnonymousIdFeature>();
//_logger.LogInformation("Photo uploading for {AnonymousId}", feature.AnonymousId);
var files = Request.Form.Files;
var path = Path.Combine(_webHostEnvironment.WebRootPath, "images");
var photos = new List<Models.Photo>();
foreach (var file in files)
{
if (file is null || file.Length == 0)
{
continue;
}
var fileName = file.FileName;
var fileSize = file.Length;
var fileNameWithPath = Path.Combine(path, fileName);
await using (var stream = new FileStream(fileNameWithPath, FileMode.Create))
{
await file.CopyToAsync(stream);
}
_logger.LogInformation("The photo has been uploaded to the server. Photo name: {FileName}", fileName);
photos.Add(new Models.Photo
{
OrijinalName = fileName,
ChangedName = "DÜZELTİLECEK",
Size = fileSize.ToString(),
SizeName = SizeName.BYTE,
//UserId = feature.AnonymousId
});
}
await _dataListContext.Photos.BulkInsertAsync(photos);
await _dataListContext.SaveChangesAsync();
return Ok();
}
}
and my last question is :)
The EntityTypeName on the "api/$metadata" page is referred to as "UserDto and PhotoDto". yes, i used naming like that. but a front-end developer will see this, why would he see that I'm doing a DTO? Let TypeName be Users or Photos. I think ODataConventionModelBuilder can be set, but is there an alternative more useful way?
Thank you for taking the time for me. @gathogojr
@mansurdegirmenci do you think you could start separate issues for the separate questions instead of posting them all here? Otherwise it will derail the original issue here a bit too much and make it harder for other folks searching for specific answers later.
Hopefully that makes sense.
I set MaxTop=100 and PageSize=100 on my controller and expanding entity.
When the data is larger than 100 then it gives the next page link for main entity and for expanding entity. NextPageLink in main entity is working but it is not working for expanding entity.
Can you please guide us on this behavior?