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

feat(webapi): Add ETag to response headers #1645

Merged
merged 3 commits into from
Jan 7, 2025
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
42 changes: 36 additions & 6 deletions docs/schema/V1/swagger.verified.json
Original file line number Diff line number Diff line change
Expand Up @@ -5863,7 +5863,12 @@
}
}
},
"description": "The UUID of the created dialog aggregate. A relative URL to the newly created activity is set in the \u0022Location\u0022 header."
"description": "The UUID of the created dialog aggregate. A relative URL to the newly created activity is set in the \u0022Location\u0022 header.",
"headers": {
"Etag": {
"description": "The new UUID ETag of the dialog"
}
}
},
"204": {
"description": "No Content"
Expand Down Expand Up @@ -5932,7 +5937,12 @@
],
"responses": {
"204": {
"description": "The dialog aggregate was deleted successfully."
"description": "The dialog aggregate was deleted successfully.",
"headers": {
"Etag": {
"description": "The new UUID ETag of the dialog"
}
}
},
"400": {
"content": {
Expand Down Expand Up @@ -6088,7 +6098,12 @@
},
"responses": {
"204": {
"description": "Patch was successfully applied."
"description": "Patch was successfully applied.",
"headers": {
"Etag": {
"description": "The new UUID ETag of the dialog"
}
}
},
"400": {
"content": {
Expand Down Expand Up @@ -6183,7 +6198,12 @@
},
"responses": {
"204": {
"description": "The dialog aggregate was updated successfully."
"description": "The dialog aggregate was updated successfully.",
"headers": {
"Etag": {
"description": "The new UUID ETag of the dialog"
}
}
},
"400": {
"content": {
Expand Down Expand Up @@ -6481,7 +6501,12 @@
}
}
},
"description": "The UUID of the created dialog activity. A relative URL to the newly created activity is set in the \u0022Location\u0022 header."
"description": "The UUID of the created dialog activity. A relative URL to the newly created activity is set in the \u0022Location\u0022 header.",
"headers": {
"Etag": {
"description": "The new UUID ETag of the dialog"
}
}
},
"204": {
"description": "No Content"
Expand Down Expand Up @@ -6844,7 +6869,12 @@
}
}
},
"description": "The UUID of the created dialog transmission. A relative URL to the newly created activity is set in the \u0022Location\u0022 header."
"description": "The UUID of the created dialog transmission. A relative URL to the newly created activity is set in the \u0022Location\u0022 header.",
"headers": {
"Etag": {
"description": "The new UUID ETag of the dialog"
}
}
},
"204": {
"description": "No Content"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@ namespace Digdir.Domain.Dialogporten.Application.Features.V1.ServiceOwner.Dialog

public sealed class CreateDialogCommand : CreateDialogDto, IRequest<CreateDialogResult>;

public sealed record CreateDialogSuccess(Guid DialogId, Guid Revision);

[GenerateOneOf]
public sealed partial class CreateDialogResult : OneOfBase<Success<Guid>, DomainError, ValidationError, Forbidden>;
public sealed partial class CreateDialogResult : OneOfBase<CreateDialogSuccess, DomainError, ValidationError, Forbidden>;

internal sealed class CreateDialogCommandHandler : IRequestHandler<CreateDialogCommand, CreateDialogResult>
{
Expand Down Expand Up @@ -93,7 +95,7 @@ public async Task<CreateDialogResult> Handle(CreateDialogCommand request, Cancel
await _db.Dialogs.AddAsync(dialog, cancellationToken);
var saveResult = await _unitOfWork.SaveChangesAsync(cancellationToken);
return saveResult.Match<CreateDialogResult>(
success => new Success<Guid>(dialog.Id),
success => new CreateDialogSuccess(dialog.Id, dialog.Revision),
domainError => domainError,
concurrencyError => throw new UnreachableException("Should never get a concurrency error when creating a new dialog"));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ public sealed class DeleteDialogCommand : IRequest<DeleteDialogResult>
}

[GenerateOneOf]
public sealed partial class DeleteDialogResult : OneOfBase<Success, EntityNotFound, EntityDeleted, Forbidden, ConcurrencyError>;
public sealed partial class DeleteDialogResult : OneOfBase<DeleteDialogSuccess, EntityNotFound, EntityDeleted, Forbidden, ConcurrencyError>;

public sealed record DeleteDialogSuccess(Guid Revision);

internal sealed class DeleteDialogCommandHandler : IRequestHandler<DeleteDialogCommand, DeleteDialogResult>
{
Expand Down Expand Up @@ -70,7 +72,7 @@ public async Task<DeleteDialogResult> Handle(DeleteDialogCommand request, Cancel
.SaveChangesAsync(cancellationToken);

return saveResult.Match<DeleteDialogResult>(
success => success,
success => new DeleteDialogSuccess(dialog.Revision),
domainError => throw new UnreachableException("Should never get a domain error when deleting a dialog"),
concurrencyError => concurrencyError);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using OneOf;
using OneOf.Types;

namespace Digdir.Domain.Dialogporten.Application.Features.V1.ServiceOwner.Dialogs.Commands.Update;

Expand All @@ -31,7 +30,9 @@ public sealed class UpdateDialogCommand : IRequest<UpdateDialogResult>
}

[GenerateOneOf]
public sealed partial class UpdateDialogResult : OneOfBase<Success, EntityNotFound, EntityDeleted, ValidationError, Forbidden, DomainError, ConcurrencyError>;
public sealed partial class UpdateDialogResult : OneOfBase<UpdateDialogSuccess, EntityNotFound, EntityDeleted, ValidationError, Forbidden, DomainError, ConcurrencyError>;

public sealed record UpdateDialogSuccess(Guid Revision);

internal sealed class UpdateDialogCommandHandler : IRequestHandler<UpdateDialogCommand, UpdateDialogResult>
{
Expand Down Expand Up @@ -167,7 +168,7 @@ public async Task<UpdateDialogResult> Handle(UpdateDialogCommand request, Cancel
.SaveChangesAsync(cancellationToken);

return saveResult.Match<UpdateDialogResult>(
success => success,
success => new UpdateDialogSuccess(dialog.Revision),
domainError => domainError,
concurrencyError => concurrencyError);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
internal static class Constants
{
internal const string IfMatch = "If-Match";
internal const string ETag = "Etag";
internal const string Authorization = "Authorization";
internal const string CurrentTokenIssuer = "CurrentIssuer";
internal const int MaxRequestBodySize = 100_000;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using Digdir.Domain.Dialogporten.WebApi.Common;
using FastEndpoints;

namespace Digdir.Domain.Dialogporten.WebApi.Endpoints.V1.Common.Headers;

public static class HttpResponseHeaderExamples
{
public static ResponseHeader NewDialogETagHeader(int statusCode)
=> new(statusCode, Constants.ETag)
{
Description = "The new UUID ETag of the dialog",
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,16 @@ await errors.Match(

var result = await _sender.Send(updateDialogCommand, ct);
await result.Match(
success => SendCreatedAtAsync<GetDialogActivityEndpoint>(new GetActivityQuery { DialogId = dialog.Id, ActivityId = req.Id.Value }, req.Id, cancellation: ct),
success =>
{
HttpContext.Response.Headers.Append(Constants.ETag, success.Revision.ToString());
return SendCreatedAtAsync<GetDialogActivityEndpoint>(
new GetActivityQuery
{
DialogId = dialog.Id,
ActivityId = req.Id.Value
}, req.Id, cancellation: ct);
},
notFound => this.NotFoundAsync(notFound, ct),
gone => this.GoneAsync(gone, ct),
validationError => this.BadRequestAsync(validationError, ct),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using Digdir.Domain.Dialogporten.WebApi.Common;
using Digdir.Domain.Dialogporten.WebApi.Common.Authorization;
using Digdir.Domain.Dialogporten.WebApi.Common.Extensions;
using Digdir.Domain.Dialogporten.WebApi.Endpoints.V1.Common.Headers;
using FastEndpoints;

namespace Digdir.Domain.Dialogporten.WebApi.Endpoints.V1.ServiceOwner.DialogActivities.Create;
Expand All @@ -18,6 +19,7 @@ The activity is created with the given configuration. For more information see t

ResponseExamples[StatusCodes.Status201Created] = "018bb8e5-d9d0-7434-8ec5-569a6c8e01fc";

ResponseHeaders = [HttpResponseHeaderExamples.NewDialogETagHeader(StatusCodes.Status201Created)];
Responses[StatusCodes.Status201Created] = Constants.SwaggerSummary.Created.FormatInvariant("activity");
Responses[StatusCodes.Status400BadRequest] = Constants.SwaggerSummary.ValidationError;
Responses[StatusCodes.Status401Unauthorized] = Constants.SwaggerSummary.ServiceOwnerAuthenticationFailure.FormatInvariant(AuthorizationScope.ServiceProvider);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,13 @@ await errors.Match(
var result = await _sender.Send(updateDialogCommand, ct);

await result.Match(
success => SendCreatedAtAsync<GetDialogTransmissionEndpoint>(new GetTransmissionQuery { DialogId = dialog.Id, TransmissionId = req.Id.Value }, req.Id, cancellation: ct),
success =>
{
HttpContext.Response.Headers.Append(Constants.ETag, success.Revision.ToString());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add strong ETag validator prefix.

Per RFC 7232, ETags should be prefixed with a strong validator (double quotes) or weak validator (W/).

-                HttpContext.Response.Headers.Append(Constants.ETag, success.Revision.ToString());
+                HttpContext.Response.Headers.Append(Constants.ETag, $"\"{success.Revision}\"");
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
HttpContext.Response.Headers.Append(Constants.ETag, success.Revision.ToString());
HttpContext.Response.Headers.Append(Constants.ETag, $"\"{success.Revision}\"");

return SendCreatedAtAsync<GetDialogTransmissionEndpoint>(
new GetTransmissionQuery { DialogId = dialog.Id, TransmissionId = req.Id.Value }, req.Id,
cancellation: ct);
},
notFound => this.NotFoundAsync(notFound, ct),
gone => this.GoneAsync(gone, ct),
validationError => this.BadRequestAsync(validationError, ct),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using Digdir.Domain.Dialogporten.WebApi.Common;
using Digdir.Domain.Dialogporten.WebApi.Common.Authorization;
using Digdir.Domain.Dialogporten.WebApi.Common.Extensions;
using Digdir.Domain.Dialogporten.WebApi.Endpoints.V1.Common.Headers;
using FastEndpoints;

namespace Digdir.Domain.Dialogporten.WebApi.Endpoints.V1.ServiceOwner.DialogTransmissions.Create;
Expand All @@ -18,6 +19,7 @@ The transmission is created with the given configuration. For more information s

ResponseExamples[StatusCodes.Status201Created] = "018bb8e5-d9d0-7434-8ec5-569a6c8e01fc";

ResponseHeaders = [HttpResponseHeaderExamples.NewDialogETagHeader(StatusCodes.Status201Created)];
Responses[StatusCodes.Status201Created] = Constants.SwaggerSummary.Created.FormatInvariant("transmission");
Responses[StatusCodes.Status400BadRequest] = Constants.SwaggerSummary.ValidationError;
Responses[StatusCodes.Status401Unauthorized] = Constants.SwaggerSummary.ServiceOwnerAuthenticationFailure.FormatInvariant(AuthorizationScope.ServiceProvider);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Digdir.Domain.Dialogporten.Application.Features.V1.ServiceOwner.Dialogs.Commands.Create;
using Digdir.Domain.Dialogporten.Application.Features.V1.ServiceOwner.Dialogs.Queries.Get;
using Digdir.Domain.Dialogporten.WebApi.Common;
using Digdir.Domain.Dialogporten.WebApi.Common.Authorization;
using Digdir.Domain.Dialogporten.WebApi.Common.Extensions;
using Digdir.Domain.Dialogporten.WebApi.Endpoints.V1.Common.Extensions;
Expand Down Expand Up @@ -34,7 +35,12 @@ public override async Task HandleAsync(CreateDialogCommand req, CancellationToke
{
var result = await _sender.Send(req, ct);
await result.Match(
success => SendCreatedAtAsync<GetDialogEndpoint>(new GetDialogQuery { DialogId = success.Value }, success.Value, cancellation: ct),
success =>
{
HttpContext.Response.Headers.Append(Constants.ETag, success.Revision.ToString());
return SendCreatedAtAsync<GetDialogEndpoint>(new GetDialogQuery { DialogId = success.DialogId },
success.DialogId, cancellation: ct);
},
domainError => this.UnprocessableEntityAsync(domainError, ct),
validationError => this.BadRequestAsync(validationError, ct),
forbidden => this.ForbiddenAsync(forbidden, ct));
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using Digdir.Domain.Dialogporten.WebApi.Common;
using Digdir.Domain.Dialogporten.WebApi.Common.Authorization;
using Digdir.Domain.Dialogporten.WebApi.Common.Extensions;
using Digdir.Domain.Dialogporten.WebApi.Endpoints.V1.Common.Headers;
using FastEndpoints;

namespace Digdir.Domain.Dialogporten.WebApi.Endpoints.V1.ServiceOwner.Dialogs.Create;
Expand All @@ -18,6 +19,7 @@ The dialog is created with the given configuration. For more information see the

ResponseExamples[StatusCodes.Status201Created] = "018bb8e5-d9d0-7434-8ec5-569a6c8e01fc";

ResponseHeaders = [HttpResponseHeaderExamples.NewDialogETagHeader(StatusCodes.Status201Created)];
Responses[StatusCodes.Status201Created] = Constants.SwaggerSummary.Created.FormatInvariant("aggregate");
Responses[StatusCodes.Status400BadRequest] = Constants.SwaggerSummary.ValidationError;
Responses[StatusCodes.Status401Unauthorized] = Constants.SwaggerSummary.ServiceOwnerAuthenticationFailure.FormatInvariant(AuthorizationScope.ServiceProvider);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,11 @@ public override async Task HandleAsync(DeleteDialogRequest req, CancellationToke
var command = new DeleteDialogCommand { Id = req.DialogId, IfMatchDialogRevision = req.IfMatchDialogRevision };
var result = await _sender.Send(command, ct);
await result.Match(
success => SendNoContentAsync(ct),
success =>
{
HttpContext.Response.Headers.Append(Constants.ETag, success.Revision.ToString());
return SendNoContentAsync(ct);
},
notFound => this.NotFoundAsync(notFound, ct),
gone => this.GoneAsync(gone, ct),
forbidden => this.ForbiddenAsync(forbidden, ct),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using Digdir.Domain.Dialogporten.WebApi.Common;
using Digdir.Domain.Dialogporten.WebApi.Common.Authorization;
using Digdir.Domain.Dialogporten.WebApi.Common.Extensions;
using Digdir.Domain.Dialogporten.WebApi.Endpoints.V1.Common.Headers;
using FastEndpoints;

namespace Digdir.Domain.Dialogporten.WebApi.Endpoints.V1.ServiceOwner.Dialogs.Delete;
Expand All @@ -18,6 +19,7 @@ Deletes a given dialog (soft delete). For more information see the documentation

Optimistic concurrency control is implemented using the If-Match header. Supply the Revision value from the GetDialog endpoint to ensure that the dialog is not deleted by another request in the meantime.
""";
ResponseHeaders = [HttpResponseHeaderExamples.NewDialogETagHeader(StatusCodes.Status204NoContent)];
Responses[StatusCodes.Status204NoContent] = Constants.SwaggerSummary.Deleted.FormatInvariant("aggregate");
Responses[StatusCodes.Status401Unauthorized] = Constants.SwaggerSummary.ServiceOwnerAuthenticationFailure.FormatInvariant(AuthorizationScope.ServiceProvider);
Responses[StatusCodes.Status403Forbidden] = Constants.SwaggerSummary.AccessDeniedToDialog.FormatInvariant("delete");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ public PatchDialogsController(ISender sender, IMapper mapper)
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status412PreconditionFailed)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
[ProducesResponseHeader(StatusCodes.Status204NoContent, Constants.ETag, "The new UUID ETag of the dialog")]
public async Task<IActionResult> Patch(
[FromRoute] Guid dialogId,
[FromHeader(Name = Constants.IfMatch)] Guid? etag,
Expand Down Expand Up @@ -87,7 +88,11 @@ public async Task<IActionResult> Patch(
var command = new UpdateDialogCommand { Id = dialogId, IfMatchDialogRevision = etag, Dto = updateDialogDto };
var result = await _sender.Send(command, ct);
return result.Match(
success => (IActionResult)NoContent(),
success =>
{
HttpContext.Response.Headers.Append(Constants.ETag, success.Revision.ToString());
return (IActionResult)NoContent();
},
notFound => NotFound(HttpContext.GetResponseOrDefault(StatusCodes.Status404NotFound, notFound.ToValidationResults())),
badRequest => BadRequest(HttpContext.GetResponseOrDefault(StatusCodes.Status400BadRequest, badRequest.ToValidationResults())),
validationFailed => BadRequest(HttpContext.GetResponseOrDefault(StatusCodes.Status400BadRequest, validationFailed.Errors.ToList())),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace Digdir.Domain.Dialogporten.WebApi.Endpoints.V1.ServiceOwner.Dialogs.Patch;

[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
public sealed class ProducesResponseHeaderAttribute : Attribute
{
public ProducesResponseHeaderAttribute(int statusCode, string headerName, string description)
{
HeaderName = headerName;
StatusCode = statusCode;
Description = description;
}

public string HeaderName { get; }
public int StatusCode { get; }
public string Description { get; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using System.Globalization;
using System.Reflection;
using NSwag;
using NSwag.Generation.Processors;
using NSwag.Generation.Processors.Contexts;

namespace Digdir.Domain.Dialogporten.WebApi.Endpoints.V1.ServiceOwner.Dialogs.Patch;

public sealed class ProducesResponseHeaderOperationProcessor : IOperationProcessor
{
public bool Process(OperationProcessorContext context)
{
var headerAttribute = context.MethodInfo.GetCustomAttribute<ProducesResponseHeaderAttribute>();
if (headerAttribute == null)
{
return true;
}

var statusCode = headerAttribute.StatusCode.ToString(CultureInfo.InvariantCulture);
var response = context.OperationDescription.Operation.Responses[statusCode];
var header = new OpenApiHeader
{
Description = headerAttribute.Description,
};

response.Headers.Add(headerAttribute.HeaderName, header);
return true;
}
Comment on lines +11 to +28
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Support multiple header attributes and improve error handling

The processor needs to handle multiple attributes since ProducesResponseHeaderAttribute has AllowMultiple = true.

 public bool Process(OperationProcessorContext context)
 {
-    var headerAttribute = context.MethodInfo.GetCustomAttribute<ProducesResponseHeaderAttribute>();
-    if (headerAttribute == null)
+    var headerAttributes = context.MethodInfo.GetCustomAttributes<ProducesResponseHeaderAttribute>();
+    if (!headerAttributes.Any())
     {
         return true;
     }

-    var statusCode = headerAttribute.StatusCode.ToString(CultureInfo.InvariantCulture);
-    var response = context.OperationDescription.Operation.Responses[statusCode];
-    var header = new OpenApiHeader
+    foreach (var headerAttribute in headerAttributes)
     {
-        Description = headerAttribute.Description,
-    };
+        var statusCode = headerAttribute.StatusCode.ToString(CultureInfo.InvariantCulture);
+        
+        if (!context.OperationDescription.Operation.Responses.TryGetValue(statusCode, out var response))
+        {
+            throw new InvalidOperationException(
+                $"Response with status code {statusCode} not found for header {headerAttribute.HeaderName}");
+        }
+        
+        var header = new OpenApiHeader
+        {
+            Description = headerAttribute.Description,
+        };
 
-    response.Headers.Add(headerAttribute.HeaderName, header);
+        response.Headers.Add(headerAttribute.HeaderName, header);
+    }
     return true;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public bool Process(OperationProcessorContext context)
{
var headerAttribute = context.MethodInfo.GetCustomAttribute<ProducesResponseHeaderAttribute>();
if (headerAttribute == null)
{
return true;
}
var statusCode = headerAttribute.StatusCode.ToString(CultureInfo.InvariantCulture);
var response = context.OperationDescription.Operation.Responses[statusCode];
var header = new OpenApiHeader
{
Description = headerAttribute.Description,
};
response.Headers.Add(headerAttribute.HeaderName, header);
return true;
}
public bool Process(OperationProcessorContext context)
{
var headerAttributes = context.MethodInfo.GetCustomAttributes<ProducesResponseHeaderAttribute>();
if (!headerAttributes.Any())
{
return true;
}
foreach (var headerAttribute in headerAttributes)
{
var statusCode = headerAttribute.StatusCode.ToString(CultureInfo.InvariantCulture);
if (!context.OperationDescription.Operation.Responses.TryGetValue(statusCode, out var response))
{
throw new InvalidOperationException(
$"Response with status code {statusCode} not found for header {headerAttribute.HeaderName}");
}
var header = new OpenApiHeader
{
Description = headerAttribute.Description,
};
response.Headers.Add(headerAttribute.HeaderName, header);
}
return true;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,11 @@ public override async Task HandleAsync(UpdateDialogRequest req, CancellationToke

var updateDialogResult = await _sender.Send(command, ct);
await updateDialogResult.Match(
success => SendNoContentAsync(ct),
success =>
{
HttpContext.Response.Headers.Append(Constants.ETag, success.Revision.ToString());
return SendNoContentAsync(ct);
},
notFound => this.NotFoundAsync(notFound, ct),
gone => this.GoneAsync(gone, ct),
validationFailed => this.BadRequestAsync(validationFailed, ct),
Expand Down
Loading
Loading