-
-
Notifications
You must be signed in to change notification settings - Fork 158
task: make [Attr] and RequestDeserializer.SetAttributes open to extension #949
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
task: make [Attr] and RequestDeserializer.SetAttributes open to extension #949
Conversation
Hi @alastairtree, I've discussed your PR with @maurei. Unsealing Attr has several downsides and we believe there are better ways to achieve what you need. We suggest to create a custom attribute and put that on your resource property, next to Attr. Then in the serialization process, you can intercept and check for the existence of your custom attribute to set the property value. AfterProcessField is an existing extensibility point you can use for that. |
Yeah, a separate attribute should work fine, so that's fine. However, AfterProcessField does not fire if the attribute is not in the attribute values dictionary as seen below. In my scenario I am trying to populate that Attr property with data that does not originate from the request body, so I need an extensibility point that grants access to all attributes, not just those in the request. Have added a couple of suggestions below - moving // in BaseDeserializer.SetAttributes
foreach (var attr in attributes)
{
// Add BeforeProcessField() extensibility point here?
if (attributeValues.TryGetValue(attr.PublicName, out object newValue))
{
if (attr.Property.SetMethod == null)
{
throw new JsonApiSerializationException("Attribute is read-only.", $"Attribute '{attr.PublicName}' is read-only.");
}
var convertedValue = ConvertAttrValue(newValue, attr.Property.PropertyType);
attr.SetValue(resource, convertedValue);
// we never arrive here if the property is not in the request payload so extension point cannot be used
AfterProcessField(resource, attr);
}
// move AfterProcessField() here?
}
return resource; |
It is not clear to us what you're trying to accomplish. For instance, are you trying to save route-data into the database, or are you trying to return extra data in the response that is based on the route? Can you elaborate on what your use case is? An example with code and request/response would probably help. |
In my specific case I want to persist an attribute whose value is calculated from the context of the request rather than from the HTTP Body. For example, say I have a multi-tenant book store app, with 2 customers, alice and bob, where my books API has a route like Then to create a book I could POST like this: POST /api/alices-store/books HTTP/1.1
Host: localhost:5001
Accept: application/vnd.api+json
Content-Type: application/vnd.api+json
{
"data": {
"type": "book",
"attributes": {
"name": "1984"
}
}
} Given this request I want to store an attribute, say public class Book : IIdentifiable<string>
{
[Attr(Capabilities = AttrCapabilities.All & ~(AttrCapabilities.AllowChange))]
public virtual string Id { get; set; }
[Attr] public string Name { get; set; }
[RouteDataAttr("tenantSlug")]
[Attr]
public string TenantId { get; set; }
public string StringId { get => Id; set => Id = value; }
} I would then be returned from the POST something like: {
"links": {
"self": "/books"
},
"data": {
"type": "books",
"id": "602b243b5b5aadc6be2e664b",
"attributes": {
"name": "1984",
"tenantId": "alices-store"
},
"links": {
"self": "/books/602b243b5b5aadc6be2e664b"
}
}
} To be able to achieve the above I need to be able to hook into json-api-dotnet somewhere where I can
AFAIK that only becomes possible by making (Lets ignore for now the fact that the links generated are imperfect - I am working on a fork of LinkBuilder that uses the MVC route data and LinkGenerator to build correct URLs - would you be interested in a PR if/when I am done?) |
Thanks for the elaboration. Setting public class ExampleCustomRequestDeserializer : IJsonApiDeserializer
{
private readonly RequestDeserializer _defaultDeserializer;
public CustomRequestDeserializer(RequestDeserializer defaultDeserializer)
{
_defaultDeserializer = defaultDeserializer;
// Also inject a mechanism here to resolve `TenantId` from route data.
}
public object Deserialize(string body)
{
var instance = _defaultDeserializer.Deserialize(body);
// Additional post processing here
// If it's just book, you could just check for this type here, otherwise consider checking
// for an interface like `IHasTenantId` and then perform the additional attribute setting here.
return instance;
}
}
// In startup:
services.AddScoped<RequestDeserializer>();
services.AddScoped<IJsonApiDeserializer, ExampleCustomRequestDeserializer>(); I'm interested to know more about your link generator. What problem does your implementation solve that is posing an obstacle with the current implementation? Feel free to open up an issue for this so we can discuss it further there. Thanks |
An alternative (simpler) way to solve this: set Anyway, this sounds like a good use case for adding "Pre-processing incoming resources (POST/PATCH)" to #934. |
@maurei I had a go at wrapping RequestDeserializer with a custom implementation as you suggested but it gets really awkward because you still need to put the Attr into _targetedFields to mark it as modified which means you need access to the ResourceObject or the Document, neither of which are available to the outer code, so you end up either deserialising it twice, or wrapping and inheriting RequestDeserializer to get access to the inner workings. Both are really hacky and no better than just rewriting the whole class. @bart-degreed implementing in IResourceWriteRepository looks like it would work, but putting custom http de-serialisation logic into the repository feels like the wrong place to me, and a violation of the SRP? I have copied and forked the whole of RequestDeserializer and BaseSerialiser(and TypeHelper) into a new class/project just to override the small part I need and it works. Not ideal but will tide me over until a supported extension point is added in #934. Thanks for the advice, will close for now and make do without a dedicated extension point. |
What I suggested is simpler because you don't need to look at In the end, your goal seems to be making the TenantId database column part of the functional key for books. So you'll need to ensure it gets assigned properly on creation. And it should not be modifiable by clients, once set. You'll also need to ensure the TenantId filter criteria is added to all book queries, otherwise alices-store can get/modify/delete books belonging to bobs-boutique. The filter criteria can be done using Global Query Filters. But because JADNC does not fetch the resource on delete, you'll need to do that yourself to prevent cross-tenant deletions. This is not that hard to accomplish with SOLID principles in mind: public interface ITenantProvider
{
string GetTenantId();
}
public class RoutingTenantProvider : ITenantProvider
{
private readonly IHttpContextAccessor _httpContextAccessor;
public RoutingTenantProvider(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
public string GetTenantId()
{
var httpContext = _httpContextAccessor.HttpContext;
return (string)httpContext.GetRouteValue("tenantSlug");
}
}
public interface IHasTenant
{
string TenantId { get; set; }
}
public class Book : Identifiable, IHasTenant
{
public string TenantId { get; set; }
}
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options, ITenantProvider tenantProvider)
: base(options)
{
_tenantProvider = tenantProvider;
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Book>()
.HasQueryFilter(book => book.TenantId == _tenantProvider.GetTenantId());
}
}
public class TenantAwareRepository<TResource> : EntityFrameworkCoreRepository<TResource>
where TResource : class, IIdentifiable<int>
{
private readonly ITenantProvider _tenantProvider;
private readonly IJsonApiRequest _request;
private readonly DbContext _dbContext;
public TenantAwareRepository(ITenantProvider tenantProvider, IJsonApiRequest request,
IQueryLayerComposer queryLayerComposer, ITargetedFields targetedFields, IDbContextResolver contextResolver,
IResourceGraph resourceGraph, IResourceFactory resourceFactory,
IEnumerable<IQueryConstraintProvider> constraintProviders, ILoggerFactory loggerFactory)
: base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory)
{
_tenantProvider = tenantProvider;
_request = request;
_dbContext = contextResolver.GetContext();
}
public override async Task<TResource> GetForCreateAsync(int id, CancellationToken cancellationToken)
{
var resource = await base.GetForCreateAsync(id, cancellationToken);
if (typeof(TResource).IsAssignableFrom(typeof(IHasTenant)))
{
string tenantId = _tenantProvider.GetTenantId();
var resourceWithTenant = (IHasTenant) resource;
resourceWithTenant.TenantId = tenantId;
}
return resource;
}
public override async Task DeleteAsync(int id, CancellationToken cancellationToken)
{
if (typeof(TResource).IsAssignableFrom(typeof(IHasTenant)))
{
string tenantId = _tenantProvider.GetTenantId();
var resourceWithTenant = await _dbContext.Set<TResource>()
.Where(resource => resource.Id == id && ((IHasTenant)resource).TenantId == tenantId)
.FirstOrDefaultAsync(cancellationToken);
if (resourceWithTenant == null)
{
throw new ResourceNotFoundException(_request.PrimaryId, _request.PrimaryResource.PublicName);
}
_dbContext.Remove(resourceWithTenant);
await base.SaveChangesAsync(cancellationToken);
}
else
{
await base.DeleteAsync(id, cancellationToken);
}
}
} and then register from services.AddSingleton<ITenantProvider, RoutingTenantProvider>();
services.AddScoped(typeof(IResourceRepository<>), typeof(TenantAwareRepository<>));
services.AddScoped(typeof(IResourceReadRepository<>), typeof(TenantAwareRepository<>));
services.AddScoped(typeof(IResourceWriteRepository<>), typeof(TenantAwareRepository<>)); Note I wrote this code without running it, you it may contain some bugs. But it should demonstrate the general idea. In my opinion this is a lot cleaner than messing with the serialization layers. |
That's really kind of you to give such a detailed suggestion and example code and now I think I understand much better what you meant. Agree it looks like a workable sensible solution. Will give it a go. The customised delete logic had come up for me before as well, where I had to put a base query to enforce permissions on all queries, so u know what you mean about having to do some extra work to enforce it. |
As an extension point I don't see how that would work. How would JADNC know the user is applying 'some kind of global query filter'? But more importantly, how would JADNC know that the global filter concerns tenant filtering, as opposed to soft-deletion or something else entirely? If you write up the code to be added to JADNC, then perhaps I better understand what you mean. |
Let's continue this conversation at #952. |
QUALITY CHECKLIST
Hello!
This is a very minor change to open a couple of types to extension, which allows me to implement some custom deserialisation logic without needing to duplicate large chunks of code. Would you be willing to accept? Please let me know if you need further work to justify such as docs etc.
Unsealing Attr allows me to create my own variant of the Attr attribute that has custom metadata about how that attr should be deserialised. Making SetAttributes virtual allows me to inherit from RequestDeserializer and override SetAttributes so that is can observer my custom attribute and implement the custom deserialisation at the correct moment so that _targetedFields gets populated. Also open to alternative implementations if opening these up is unfavourable.
(My requirement is that my custom Attr attribute triggers the property to be populated from route data rather than from the JSON Body and although that is outside the scope of this PR it might help explain why these are required)
Thanks for the great project.