Skip to content
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

Allow flat id to be set #45

Merged
merged 7 commits into from
Sep 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,81 @@ public async Task UpdateCollection_UpdatesCollection_WhenAllValuesProvided()
responseCollection.View.Id.Should().Contain("?page=1&pageSize=20");
}

[Fact]
public async Task UpdateCollection_CreatesCollection_WhenUnknownCollectionIdProvided()
{
// Arrange
var updatedCollection = new UpsertFlatCollection()
{
Behavior = new List<string>()
{
Behavior.IsPublic,
Behavior.IsStorageCollection
},
Label = new LanguageMap("en", ["test collection - create from update"]),
Slug = "create-from-update",
Parent = parent,
ItemsOrder = 1,
Thumbnail = "some/location/2",
Tags = "some, tags, 2",
};

var updateRequestMessage = HttpRequestMessageBuilder.GetPrivateRequest(HttpMethod.Put,
$"{Customer}/collections/createFromUpdate", JsonSerializer.Serialize(updatedCollection));

// Act
var response = await httpClient.AsCustomer(1).SendAsync(updateRequestMessage);

var responseCollection = await response.ReadAsPresentationResponseAsync<FlatCollection>();

var fromDatabase = dbContext.Collections.First(c => c.Id == responseCollection!.Id!.Split('/', StringSplitOptions.TrimEntries).Last());

// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
fromDatabase.Id.Should().Be("createFromUpdate");
fromDatabase.Parent.Should().Be(parent);
fromDatabase.Label!.Values.First()[0].Should().Be("test collection - create from update");
fromDatabase.Slug.Should().Be("create-from-update");
fromDatabase.ItemsOrder.Should().Be(1);
fromDatabase.Thumbnail.Should().Be("some/location/2");
fromDatabase.Tags.Should().Be("some, tags, 2");
fromDatabase.IsPublic.Should().BeTrue();
fromDatabase.IsStorageCollection.Should().BeTrue();
responseCollection!.View!.PageSize.Should().Be(20);
responseCollection.View.Page.Should().Be(1);
responseCollection.View.Id.Should().Contain("?page=1&pageSize=20");
}

[Fact]
public async Task UpdateCollection_FailsToCreateCollection_WhenUnknownCollectionWithETag()
{
// Arrange
var updatedCollection = new UpsertFlatCollection()
{
Behavior = new List<string>()
{
Behavior.IsPublic,
Behavior.IsStorageCollection
},
Label = new LanguageMap("en", ["test collection - create from update"]),
Slug = "create-from-update-2",
Parent = parent,
ItemsOrder = 1,
Thumbnail = "some/location/2",
Tags = "some, tags, 2",
};

var updateRequestMessage = HttpRequestMessageBuilder.GetPrivateRequest(HttpMethod.Put,
$"{Customer}/collections/createFromUpdate2", JsonSerializer.Serialize(updatedCollection));
updateRequestMessage.Headers.IfMatch.Add(new EntityTagHeaderValue("\"someTag\""));

// Act
var response = await httpClient.AsCustomer(1).SendAsync(updateRequestMessage);

// Assert
response.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed);
}

[Fact]
public async Task UpdateCollection_UpdatesCollection_WhenAllValuesProvidedWithoutLabel()
{
Expand Down
72 changes: 18 additions & 54 deletions src/IIIFPresentation/API/Attributes/EtagCachingAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@

namespace API.Attributes;

public class EtagCachingAttribute : ActionFilterAttribute
public class ETagCachingAttribute : ActionFilterAttribute
{
// When a "304 Not Modified" response is to be sent back to the client, all headers apart from the following list should be stripped from the response to keep the response size minimal. See https://datatracker.ietf.org/doc/html/rfc7232#section-4.1:~:text=200%20(OK)%20response.-,The%20server%20generating%20a%20304,-response%20MUST%20generate
private static readonly string[] headersToKeepFor304 =
private static readonly string[] HeadersToKeepFor304 =
{
HeaderNames.CacheControl,
HeaderNames.ContentLocation,
Expand All @@ -22,8 +22,6 @@ public class EtagCachingAttribute : ActionFilterAttribute
HeaderNames.Vary
};

private static readonly Dictionary<string, string> etagHashes = new();

// Adds cache headers to response
public override async Task OnResultExecutionAsync(
ResultExecutingContext context,
Expand All @@ -32,6 +30,7 @@ ResultExecutionDelegate next
{
var request = context.HttpContext.Request;
var response = context.HttpContext.Response;
var eTagManager = context.HttpContext.RequestServices.GetService<IETagManager>()!;

// For more info on this technique, see https://stackoverflow.com/a/65901913 and https://www.madskristensen.net/blog/send-etag-headers-in-aspnet-core/ and https://gist.github.com/madskristensen/36357b1df9ddbfd123162cd4201124c4
var originalStream = response.Body;
Expand All @@ -41,21 +40,21 @@ ResultExecutionDelegate next
await next();
memoryStream.Position = 0;

if (response.StatusCode == StatusCodes.Status200OK)
if (response.StatusCode is StatusCodes.Status200OK or StatusCodes.Status201Created)
{
var responseHeaders = response.GetTypedHeaders();

responseHeaders.CacheControl = new CacheControlHeaderValue() // how long clients should cache the response
{
Public = request.HasShowExtraHeader(),
MaxAge = TimeSpan.FromDays(365)
MaxAge = TimeSpan.FromSeconds(eTagManager.CacheTimeoutSeconds)
};

if (IsEtagSupported(response))
{
responseHeaders.ETag ??=
GenerateETag(memoryStream,
request.Path); // This request generates a hash from the response - this would come from S3 in live
request.Path, eTagManager!); // This request generates a hash from the response - this would come from S3 in live
}

var requestHeaders = request.GetTypedHeaders();
Expand All @@ -66,8 +65,10 @@ ResultExecutionDelegate next

// Remove all unnecessary headers while only keeping the ones that should be included in a `304` response.
foreach (var header in response.Headers)
if (!headersToKeepFor304.Contains(header.Key))
response.Headers.Remove(header.Key);
if (!HeadersToKeepFor304.Contains(header.Key))
JackLewis-digirati marked this conversation as resolved.
Show resolved Hide resolved
{
response.Headers.Remove(header.Key);
}

return;
}
Expand All @@ -81,27 +82,23 @@ await memoryStream
private static bool IsEtagSupported(HttpResponse response)
{
// 20kb length limit - can be changed
if (response.Body.Length > 20 * 1024)
return false;
if (response.Body.Length > 20 * 1024) return false;

if (response.Headers.ContainsKey(HeaderNames.ETag))
return false;
if (response.Headers.ContainsKey(HeaderNames.ETag)) return false;

return true;
}

private static EntityTagHeaderValue GenerateETag(Stream stream, string path)
private static EntityTagHeaderValue GenerateETag(Stream stream, string path, IETagManager eTagManager)
{
var hashBytes = MD5.HashData(stream);
stream.Position = 0;
var hashString = Convert.ToBase64String(hashBytes);

var enityTagHeader =
new EntityTagHeaderValue('"' + hashString +
'"');
var entityTagHeader = new EntityTagHeaderValue($"\"{hashString}\"");

etagHashes[path] = enityTagHeader.Tag.ToString();
return enityTagHeader;
eTagManager.UpsertETag(path, entityTagHeader.Tag.ToString());
return entityTagHeader;
}

private static bool IsClientCacheValid(RequestHeaders reqHeaders, ResponseHeaders resHeaders)
Expand All @@ -114,43 +111,10 @@ private static bool IsClientCacheValid(RequestHeaders reqHeaders, ResponseHeader
);

if (reqHeaders.IfModifiedSince is not null && resHeaders.LastModified is not null)
return reqHeaders.IfModifiedSince >= resHeaders.LastModified;

return false;
}

// checks request for valid cache headers
public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(next);

var request = context.HttpContext.Request;

if (request.Method == HttpMethod.Put.ToString())
{
if (request.Headers.IfMatch.Count == 0)
context.Result = new ObjectResult("This method requires a valid ETag to be present")
{
StatusCode = StatusCodes.Status400BadRequest
};

etagHashes.TryGetValue(request.Path, out var etag);

if (!request.Headers.IfMatch.Equals(etag))
{
context.Result = new ObjectResult(new Error()
{
Detail = "Cannot match ETag",
Status = 412
})
{
StatusCode = StatusCodes.Status412PreconditionFailed
};
}
return reqHeaders.IfModifiedSince >= resHeaders.LastModified;
}

OnActionExecuting(context);
if (context.Result == null) OnActionExecuted(await next());
return false;
}
}
105 changes: 0 additions & 105 deletions src/IIIFPresentation/API/Features/Storage/Requests/UpdateCollection.cs

This file was deleted.

Loading
Loading