Skip to content

Explores support for concurrency tokens using PostgreSQL, continued #1119

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

Draft
wants to merge 5 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Added input validation for version in URL and request body
  • Loading branch information
Bart Koelman authored and bkoelman committed Oct 5, 2022
commit 2001becabc7af19c0a3edb772485ae8d9e258d5f
35 changes: 35 additions & 0 deletions src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ public async Task InvokeAsync(HttpContext httpContext, IControllerResourceMappin

SetupResourceRequest((JsonApiRequest)request, primaryResourceType, routeValues, httpContext.Request);

if (!await ValidateVersionAsync(request, httpContext, options.SerializerWriteOptions))
{
return;
}

httpContext.RegisterJsonApiRequest();
}
else if (IsRouteForOperations(routeValues))
Expand Down Expand Up @@ -192,6 +197,36 @@ private static async Task<bool> ValidateAcceptHeaderAsync(MediaTypeHeaderValue a
return true;
}

private static async Task<bool> ValidateVersionAsync(IJsonApiRequest request, HttpContext httpContext, JsonSerializerOptions serializerOptions)
{
if (!request.IsReadOnly)
{
if (request.PrimaryResourceType!.IsVersioned && request.WriteOperation != WriteOperationKind.CreateResource && request.PrimaryVersion == null)
{
await FlushResponseAsync(httpContext.Response, serializerOptions, new ErrorObject(HttpStatusCode.BadRequest)
{
Title = "The 'version' parameter is required at this endpoint.",
Detail = $"Resources of type '{request.PrimaryResourceType.PublicName}' require the version to be specified."
});

return false;
}

if (!request.PrimaryResourceType.IsVersioned && request.PrimaryVersion != null)
{
await FlushResponseAsync(httpContext.Response, serializerOptions, new ErrorObject(HttpStatusCode.BadRequest)
{
Title = "The 'version' parameter is not supported at this endpoint.",
Detail = $"Resources of type '{request.PrimaryResourceType.PublicName}' are not versioned."
});

return false;
}
}

return true;
}

private static async Task FlushResponseAsync(HttpResponse httpResponse, JsonSerializerOptions serializerOptions, ErrorObject error)
{
httpResponse.ContentType = HeaderConstants.MediaType;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@ private static ResourceIdentityRequirements CreateDataRequirements(AtomicReferen
IdConstraint = refRequirements.IdConstraint,
IdValue = refResult.Resource.StringId,
LidValue = refResult.Resource.LocalId,
VersionConstraint = !refResult.ResourceType.IsVersioned ? JsonElementConstraint.Forbidden : null,
VersionValue = refResult.Resource.GetVersion(),
RelationshipName = refResult.Relationship?.PublicName
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,11 @@ private ResourceIdentityRequirements CreateIdentityRequirements(RequestAdapterSt
{
ResourceType = state.Request.PrimaryResourceType,
IdConstraint = idConstraint,
IdValue = state.Request.PrimaryId
IdValue = state.Request.PrimaryId,
VersionConstraint = state.Request.PrimaryResourceType!.IsVersioned && state.Request.WriteOperation != WriteOperationKind.CreateResource
? JsonElementConstraint.Required
: JsonElementConstraint.Forbidden,
VersionValue = state.Request.PrimaryVersion
};

return requirements;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Collections;
using JsonApiDotNetCore.Middleware;
using JsonApiDotNetCore.Resources;
using JsonApiDotNetCore.Resources.Annotations;
using JsonApiDotNetCore.Serialization.Objects;
Expand Down Expand Up @@ -73,6 +74,8 @@ private static SingleOrManyData<ResourceIdentifierObject> ToIdentifierData(Singl
{
ResourceType = relationship.RightType,
IdConstraint = JsonElementConstraint.Required,
VersionConstraint = !relationship.RightType.IsVersioned ? JsonElementConstraint.Forbidden :
state.Request.Kind == EndpointKind.AtomicOperations ? null : JsonElementConstraint.Required,
RelationshipName = relationship.PublicName
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ protected ResourceIdentityAdapter(IResourceGraph resourceGraph, IResourceFactory
ArgumentGuard.NotNull(state);

ResourceType resourceType = ResolveType(identity, requirements, state);
IIdentifiable resource = CreateResource(identity, requirements, resourceType.ClrType, state);
IIdentifiable resource = CreateResource(identity, requirements, resourceType, state);

return (resource, resourceType);
}
Expand Down Expand Up @@ -93,7 +93,8 @@ private static void AssertIsCompatibleResourceType(ResourceType actual, Resource
}
}

private IIdentifiable CreateResource(ResourceIdentity identity, ResourceIdentityRequirements requirements, Type resourceClrType, RequestAdapterState state)
private IIdentifiable CreateResource(ResourceIdentity identity, ResourceIdentityRequirements requirements, ResourceType resourceType,
RequestAdapterState state)
{
if (state.Request.Kind != EndpointKind.AtomicOperations)
{
Expand All @@ -111,10 +112,20 @@ private IIdentifiable CreateResource(ResourceIdentity identity, ResourceIdentity
AssertHasNoId(identity, state);
}

if (requirements.VersionConstraint == JsonElementConstraint.Required)
{
AssertHasVersion(identity, state);
}
else if (!resourceType.IsVersioned || requirements.VersionConstraint == JsonElementConstraint.Forbidden)
{
AssertHasNoVersion(identity, state);
}

AssertSameIdValue(identity, requirements.IdValue, state);
AssertSameLidValue(identity, requirements.LidValue, state);
AssertSameVersionValue(identity, requirements.VersionValue, state);

IIdentifiable resource = _resourceFactory.CreateInstance(resourceClrType);
IIdentifiable resource = _resourceFactory.CreateInstance(resourceType.ClrType);
AssignStringId(identity, resource, state);
resource.LocalId = identity.Lid;
resource.SetVersion(identity.Version);
Expand Down Expand Up @@ -171,6 +182,23 @@ private static void AssertHasNoId(ResourceIdentity identity, RequestAdapterState
}
}

private static void AssertHasVersion(ResourceIdentity identity, RequestAdapterState state)
{
if (identity.Version == null)
{
throw new ModelConversionException(state.Position, "The 'version' element is required.", null);
}
}

private static void AssertHasNoVersion(ResourceIdentity identity, RequestAdapterState state)
{
if (identity.Version != null)
{
using IDisposable _ = state.Position.PushElement("version");
throw new ModelConversionException(state.Position, "Unexpected 'version' element.", null);
}
}

private static void AssertSameIdValue(ResourceIdentity identity, string? expected, RequestAdapterState state)
{
if (expected != null && identity.Id != expected)
Expand All @@ -193,6 +221,17 @@ private static void AssertSameLidValue(ResourceIdentity identity, string? expect
}
}

private static void AssertSameVersionValue(ResourceIdentity identity, string? expected, RequestAdapterState state)
{
if (expected != null && identity.Version != expected)
{
using IDisposable _ = state.Position.PushElement("version");

throw new ModelConversionException(state.Position, "Conflicting 'version' values found.", $"Expected '{expected}' instead of '{identity.Version}'.",
HttpStatusCode.Conflict);
}
}

private void AssignStringId(ResourceIdentity identity, IIdentifiable resource, RequestAdapterState state)
{
if (identity.Id != null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,16 @@ public sealed class ResourceIdentityRequirements
/// </summary>
public string? LidValue { get; init; }

/// <summary>
/// When not null, indicates the presence or absence of the "version" element.
/// </summary>
public JsonElementConstraint? VersionConstraint { get; init; }

/// <summary>
/// When not null, indicates what the value of the "version" element must be.
/// </summary>
public string? VersionValue { get; init; }

/// <summary>
/// When not null, indicates the name of the relationship to use in error messages.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public sealed class ConcurrencyDbContext : DbContext
public DbSet<WebImage> WebImages => Set<WebImage>();
public DbSet<PageFooter> PageFooters => Set<PageFooter>();
public DbSet<WebLink> WebLinks => Set<WebLink>();
public DbSet<DeploymentJob> DeploymentJobs => Set<DeploymentJob>();

public ConcurrencyDbContext(DbContextOptions<ConcurrencyDbContext> options)
: base(options)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,17 @@ internal sealed class ConcurrencyFakers : FakerContainer
.RuleFor(webLink => webLink.Url, faker => faker.Internet.Url())
.RuleFor(webLink => webLink.OpensInNewTab, faker => faker.Random.Bool()));

private readonly Lazy<Faker<DeploymentJob>> _lazyDeploymentJobFaker = new(() =>
new Faker<DeploymentJob>()
.UseSeed(GetFakerSeed())
.RuleFor(deploymentJob => deploymentJob.StartedAt, faker => faker.Date.PastOffset()));

public Faker<WebPage> WebPage => _lazyWebPageFaker.Value;
public Faker<FriendlyUrl> FriendlyUrl => _lazyFriendlyUrlFaker.Value;
public Faker<TextBlock> TextBlock => _lazyTextBlockFaker.Value;
public Faker<Paragraph> Paragraph => _lazyParagraphFaker.Value;
public Faker<WebImage> WebImage => _lazyWebImageFaker.Value;
public Faker<PageFooter> PageFooter => _lazyPageFooterFaker.Value;
public Faker<WebLink> WebLink => _lazyWebLinkFaker.Value;
public Faker<DeploymentJob> DeploymentJob => _lazyDeploymentJobFaker.Value;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations;
using JetBrains.Annotations;
using JsonApiDotNetCore.Resources;
using JsonApiDotNetCore.Resources.Annotations;

namespace JsonApiDotNetCoreTests.IntegrationTests.OptimisticConcurrency;

[UsedImplicitly(ImplicitUseTargetFlags.Members)]
[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.OptimisticConcurrency")]
public sealed class DeploymentJob : Identifiable<Guid>
{
[Attr]
[Required]
public DateTimeOffset? StartedAt { get; set; }

[HasOne]
public DeploymentJob? ParentJob { get; set; }

[HasMany]
public IList<DeploymentJob> ChildJobs { get; set; } = new List<DeploymentJob>();
}
Loading