Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
28fb6c6
Stream Correspondence attachments to support large file upload
Ceredron Aug 4, 2025
c8d5744
Fix build
Ceredron Aug 4, 2025
1d1d245
Fix build
Ceredron Aug 4, 2025
0a37109
Revert "Fix build"
Ceredron Aug 4, 2025
6fc12c1
Revert "Fix build"
Ceredron Aug 4, 2025
6f1e076
Fix tests
Ceredron Aug 5, 2025
29fb277
Typo
Ceredron Aug 5, 2025
eb5cb3a
Fix more tests
Ceredron Aug 5, 2025
c8d453d
Merge branch 'main' into feat/streamed-correspondence-upload
Ceredron Aug 5, 2025
fb8f85a
Formatting
Ceredron Aug 5, 2025
61805b8
Merge branch 'feat/streamed-correspondence-upload' of https://github.…
Ceredron Aug 5, 2025
d3a6ad1
Break API
Ceredron Aug 5, 2025
33dcf0b
Expanded interface to include stream implementation
Ceredron Aug 5, 2025
4af8625
Break
Ceredron Aug 5, 2025
2dccc09
Tempfix for testing streaming response
Ceredron Aug 5, 2025
ad99a4d
Only stream when necessary
Ceredron Aug 5, 2025
96211e1
"Non-breaking" version
Ceredron Aug 5, 2025
55b8fde
Fix build
Ceredron Aug 5, 2025
4165255
Bang some tests
Ceredron Aug 5, 2025
e6a0504
Re-factor to try to break less
Ceredron Aug 6, 2025
460e830
Use ResponseStreamWrapper to ensure correct disposal of response mess…
Ceredron Aug 6, 2025
7c7031d
Xml docs
Ceredron Aug 6, 2025
cc5de34
Streams needs to be suffixed with Stream
Ceredron Aug 6, 2025
8821d7b
Small fixes
Ceredron Aug 6, 2025
f30c680
More fix
Ceredron Aug 6, 2025
574f29a
Internalize 'ResponseWrapperStream', disposal, extend XML docs for c…
martinothamar Aug 6, 2025
dbd86ab
Experiment to use REST instead of Form endpoints
Ceredron Aug 6, 2025
723e980
Formatting etc
Ceredron Aug 6, 2025
527bf2c
API for experiment
Ceredron Aug 6, 2025
351cddd
Revert "API for experiment"
Ceredron Aug 6, 2025
dc7485a
Revert "Formatting etc"
Ceredron Aug 6, 2025
351ad4f
Revert "Experiment to use REST instead of Form endpoints"
Ceredron Aug 6, 2025
d600d0a
Merge branch 'main' into feat/streamed-correspondence-upload
martinothamar Aug 7, 2025
0379ca8
New experiment
Ceredron Aug 7, 2025
f8ae91f
Merge branch 'feat/streamed-correspondence-upload' of https://github.…
Ceredron Aug 7, 2025
2204363
Remove usings
Ceredron Aug 7, 2025
1c3d196
Fix logic and added test
Ceredron Aug 7, 2025
b695d8c
Fix formatting
Ceredron Aug 7, 2025
e96ee08
Revert test
Ceredron Aug 7, 2025
8a59a16
Added content length forwarding
Ceredron Aug 7, 2025
570c6b6
Added xmldoc
Ceredron Aug 7, 2025
c37765b
Chunk encoding
Ceredron Aug 7, 2025
20b0ba2
Fix bug with wrong reference to existingAttachments field
Ceredron Aug 7, 2025
a10726a
Revert length in response wrapper
Ceredron Aug 7, 2025
b1b78d0
Empty out references to now spun streams
Ceredron Aug 7, 2025
b4114d9
ExistingAttachments test fixes
Ceredron Aug 7, 2025
6ead297
Remove old xmldoc
Ceredron Aug 7, 2025
7754775
Simplify httpclient because no longer big individual calls
Ceredron Aug 7, 2025
ad0064d
Try to increase timeout for Storage connection
Ceredron Aug 11, 2025
a2a4320
Merge branch 'main' into feat/upload-using-json
Ceredron Aug 11, 2025
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
30 changes: 30 additions & 0 deletions src/Altinn.App.Core/Extensions/HttpClientExtension.cs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,36 @@ public static async Task<HttpResponseMessage> GetAsync(
return await httpClient.SendAsync(request, HttpCompletionOption.ResponseContentRead, CancellationToken.None);
}

/// <summary>
/// Extension that add authorization header to request
/// </summary>
/// <param name="httpClient">The HttpClient</param>
/// <param name="authorizationToken">the authorization token (jwt)</param>
/// <param name="requestUri">The request Uri</param>
/// <param name="platformAccessToken">The platformAccess tokens</param>
/// <returns>A HttpResponseMessage</returns>
public static async Task<HttpResponseMessage> GetStreamingAsync(
this HttpClient httpClient,
string authorizationToken,
string requestUri,
string? platformAccessToken = null
)
{
using HttpRequestMessage request = new(HttpMethod.Get, requestUri);

request.Headers.Authorization = new AuthenticationHeaderValue(
Constants.AuthorizationSchemes.Bearer,
authorizationToken
);

if (!string.IsNullOrEmpty(platformAccessToken))
{
request.Headers.Add(Constants.General.PlatformAccessTokenHeaderName, platformAccessToken);
}

return await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, CancellationToken.None);
}

/// <summary>
/// Extension that add authorization header to request
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
private string? _filename;
private string? _sendersReference;
private ReadOnlyMemory<byte>? _data;
private Stream? _streamedData;
private bool? _isEncrypted;
private CorrespondenceDataLocationType _dataLocationType =
CorrespondenceDataLocationType.ExistingCorrespondenceAttachment;
Expand Down Expand Up @@ -40,11 +41,17 @@
/// <inheritdoc/>
public ICorrespondenceAttachmentBuilder WithData(ReadOnlyMemory<byte> data)
{
BuilderUtils.NotNullOrEmpty(data, "Data cannot be empty");
_data = data;
return this;
}

/// <inheritdoc/>
public ICorrespondenceAttachmentBuilder WithData(Stream data)
{
_streamedData = data;
return this;
}

/// <inheritdoc/>
public ICorrespondenceAttachmentBuilder WithIsEncrypted(bool isEncrypted)
{
Expand All @@ -64,15 +71,31 @@
{
BuilderUtils.NotNullOrEmpty(_filename);
BuilderUtils.NotNullOrEmpty(_sendersReference);
BuilderUtils.NotNullOrEmpty(_data);
BuilderUtils.RequireExactlyOneOf(_data, _streamedData);

return new CorrespondenceAttachment
if (_streamedData is not null)
{
BuilderUtils.NotNullOrEmpty(_streamedData);
return new CorrespondenceStreamedAttachment
{
Filename = _filename,
SendersReference = _sendersReference,
Data = _streamedData,
IsEncrypted = _isEncrypted,
DataLocationType = _dataLocationType,
};
}
else
{
Filename = _filename,
SendersReference = _sendersReference,
Data = _data.Value,
IsEncrypted = _isEncrypted,
DataLocationType = _dataLocationType,
};
BuilderUtils.NotNullOrEmpty(_data);
return new CorrespondenceAttachmentInMemory
{
Filename = _filename,
SendersReference = _sendersReference,

Check warning

Code scanning / CodeQL

Dereferenced variable may be null Warning

Variable
this._data
may be null at this access because it has a nullable type.
Data = _data.Value,
IsEncrypted = _isEncrypted,
DataLocationType = _dataLocationType,
};
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public interface ICorrespondenceAttachmentBuilderSendersReference
}

/// <summary>
/// Indicates that the <see cref="CorrespondenceAttachmentBuilder"/> instance is on the <see cref="CorrespondenceAttachment.Data"/> step.
/// Indicates that the <see cref="CorrespondenceAttachmentBuilder"/> instance is on the <see cref="CorrespondenceAttachmentInMemory.Data"/> and <see cref="CorrespondenceStreamedAttachment.Data"/> step.
/// </summary>
public interface ICorrespondenceAttachmentBuilderData
{
Expand All @@ -36,6 +36,15 @@ public interface ICorrespondenceAttachmentBuilderData
/// </summary>
/// <param name="data">The data</param>
ICorrespondenceAttachmentBuilder WithData(ReadOnlyMemory<byte> data);

/// <summary>
/// Sets the stream of the data content of the attachment.
/// Is more efficient if the attachment is large in size.
/// The stream must be open (not disposed) until the correspondence is sent.
/// The caller is responsible for disposing the stream after the correspondence has been sent.
/// </summary>
/// <param name="data">The data stream</param>
ICorrespondenceAttachmentBuilder WithData(Stream data);
}

/// <summary>
Expand Down
107 changes: 107 additions & 0 deletions src/Altinn.App.Core/Features/Correspondence/CorrespondenceClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using Altinn.App.Core.Constants;
using Altinn.App.Core.Features.Correspondence.Exceptions;
using Altinn.App.Core.Features.Correspondence.Models;
using Altinn.App.Core.Features.Correspondence.Models.Response;
using Altinn.App.Core.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
Expand Down Expand Up @@ -50,6 +51,72 @@

try
{
if (
payload.CorrespondenceRequest.Content.Attachments?.Count > 0
&& payload.CorrespondenceRequest.Content.Attachments.All(a => a is CorrespondenceStreamedAttachment)
)
{
var pollingJobs = new List<Task>();
var premadeAttachments = new List<Guid>();
foreach (
CorrespondenceStreamedAttachment attachment in payload.CorrespondenceRequest.Content.Attachments
)
{
var initializeAttachmentPayload = new AttachmentPayload
{
DisplayName = attachment.Filename,
FileName = attachment.Filename,
IsEncrypted = attachment.IsEncrypted ?? false,
ResourceId = payload.CorrespondenceRequest.ResourceId,
SendersReference = payload.CorrespondenceRequest.SendersReference + " - " + attachment.Filename,
};
var initializeAttachmentRequest = await AuthenticatedHttpRequestFactory(
method: HttpMethod.Post,
uri: GetUri("attachment"),
content: new StringContent(
JsonSerializer.Serialize(initializeAttachmentPayload),
new MediaTypeHeaderValue("application/json")
),
payload: payload
);
var initializeAttachmentResponse = await HandleServerCommunication<string>(
initializeAttachmentRequest,
cancellationToken
);
var initializeAttachmentContent = initializeAttachmentResponse;
if (initializeAttachmentContent is null)
{
throw new CorrespondenceRequestException(
"Attachment initialization request did not return content.",
null,
HttpStatusCode.InternalServerError,
"No content returned from attachment initialization"
);
}
var attachmentId = initializeAttachmentContent.Trim('"');
HttpContent attachmentDataContent = new StreamContent(attachment.Data);
attachmentDataContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
attachmentDataContent.Headers.TryAddWithoutValidation("Transfer-Encoding", "Chunked");
var uploadAttachmentRequest = await AuthenticatedHttpRequestFactory(
method: HttpMethod.Post,
uri: GetUri($"attachment/{attachmentId}/upload"),
content: attachmentDataContent,
payload: payload
);
var uploadAttachmentResponse = await HandleServerCommunication<AttachmentOverview>(
uploadAttachmentRequest,
cancellationToken
);
Comment on lines +106 to +109

Check warning

Code scanning / CodeQL

Useless assignment to local variable Warning

This assignment to
uploadAttachmentResponse
is useless, since its value is never read.
if (Guid.TryParse(attachmentId, out var guidId))
{
premadeAttachments.Add(guidId);
pollingJobs.Add(PollAttachmentStatus(guidId, payload, cancellationToken));
}
}
await Task.WhenAll(pollingJobs);
payload.CorrespondenceRequest.ExistingAttachments = premadeAttachments;
payload.CorrespondenceRequest.Content.Attachments = [];
}
using MultipartFormDataContent content = payload.CorrespondenceRequest.Serialise();
using HttpRequestMessage request = await AuthenticatedHttpRequestFactory(
method: HttpMethod.Post,
Expand Down Expand Up @@ -88,6 +155,45 @@
}
}

/// <inheritdoc />
private async Task PollAttachmentStatus(
Guid attachmentId,
CorrespondencePayloadBase payload,
CancellationToken cancellationToken
)
{
const int maxAttempts = 3;
const int delaySeconds = 5;

for (int attempt = 1; attempt <= maxAttempts; attempt++)
{
await Task.Delay(TimeSpan.FromSeconds(delaySeconds), cancellationToken);

var statusRequest = await AuthenticatedHttpRequestFactory(
method: HttpMethod.Get,
uri: GetUri($"attachment/{attachmentId}"),
content: null,
payload: payload
);
var statusResponse = await HandleServerCommunication<AttachmentOverview>(statusRequest, cancellationToken);

if (statusResponse is null)
{
break;
}
if (statusResponse.Status == "Published")
{
return;
}
}
throw new CorrespondenceRequestException(
$"Failure when uploading attachment. Attachment was not published in time.",
null,
HttpStatusCode.InternalServerError,
"Polling failed"
);
}

/// <inheritdoc/>
public async Task<GetCorrespondenceStatusResponse> GetStatus(
GetCorrespondenceStatusPayload payload,
Expand Down Expand Up @@ -191,6 +297,7 @@
)
{
using HttpClient client = _httpClientFactory.CreateClient();

using HttpResponseMessage response = await client.SendAsync(request, cancellationToken);
string responseBody = await response.Content.ReadAsStringAsync(cancellationToken);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
namespace Altinn.App.Core.Features.Correspondence.Models;

/// <summary>
/// Represents the payload for sending an attachment.
/// </summary>
internal class AttachmentPayload
{
/// <summary>
/// Gets or sets the Resource Id for the correspondence service.
/// </summary>
public required string ResourceId { get; set; }

/// <summary>
/// The name of the attachment file.
/// </summary>
public string? FileName { get; set; }

/// <summary>
/// A logical name for the file, which will be shown in Altinn Inbox.
/// </summary>
public string? DisplayName { get; set; }

/// <summary>
/// A value indicating whether the attachment is encrypted or not.
/// </summary>
public required bool IsEncrypted { get; set; }

/// <summary>
/// A reference value given to the attachment by the creator.
/// </summary>
public required string SendersReference { get; set; }
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
namespace Altinn.App.Core.Features.Correspondence.Models;

/// <summary>
/// Represents an attachment to a correspondence.
/// Represents a base attachment of a correspondence.
/// </summary>
public sealed record CorrespondenceAttachment : MultipartCorrespondenceItem
public abstract record CorrespondenceAttachment : MultipartCorrespondenceItem
{
/// <summary>
/// The filename of the attachment.
Expand All @@ -27,20 +27,7 @@ public sealed record CorrespondenceAttachment : MultipartCorrespondenceItem
CorrespondenceDataLocationType.ExistingCorrespondenceAttachment;

/// <summary>
/// The data content.
/// Serialise method
/// </summary>
public required ReadOnlyMemory<byte> Data { get; init; }

internal void Serialise(MultipartFormDataContent content, int index, string? filenameOverride = null)
{
const string typePrefix = "Correspondence.Content.Attachments";
string prefix = $"{typePrefix}[{index}]";
string actualFilename = filenameOverride ?? Filename;

AddRequired(content, actualFilename, $"{prefix}.Filename");
AddRequired(content, SendersReference, $"{prefix}.SendersReference");
AddRequired(content, DataLocationType.ToString(), $"{prefix}.DataLocationType");
AddRequired(content, Data, "Attachments", actualFilename); // NOTE: No prefix!
AddIfNotNull(content, IsEncrypted?.ToString(), $"{prefix}.IsEncrypted");
}
internal abstract void Serialise(MultipartFormDataContent content, int index, string? filenameOverride = null);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
namespace Altinn.App.Core.Features.Correspondence.Models;

/// <summary>
/// Represents an attachment to a correspondence.
/// </summary>
public record CorrespondenceAttachmentInMemory : CorrespondenceAttachment
{
/// <summary>
/// The data content.
/// </summary>
public required ReadOnlyMemory<byte> Data { get; init; }

internal override void Serialise(MultipartFormDataContent content, int index, string? filenameOverride = null)
{
const string typePrefix = "Correspondence.Content.Attachments";
string prefix = $"{typePrefix}[{index}]";
string actualFilename = filenameOverride ?? Filename;

AddRequired(content, actualFilename, $"{prefix}.Filename");
AddRequired(content, SendersReference, $"{prefix}.SendersReference");
AddRequired(content, DataLocationType.ToString(), $"{prefix}.DataLocationType");
AddRequired(content, Data, "Attachments", actualFilename);
AddIfNotNull(content, IsEncrypted?.ToString(), $"{prefix}.IsEncrypted");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public sealed record CorrespondenceContent : MultipartCorrespondenceItem
/// <summary>
/// File attachments to associate with this correspondence.
/// </summary>
public IReadOnlyList<CorrespondenceAttachment>? Attachments { get; init; }
public IReadOnlyList<CorrespondenceAttachment>? Attachments { get; set; }

internal void Serialise(MultipartFormDataContent content)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ string filename
content.Add(new ReadOnlyMemoryContent(data), name, filename);
}

internal static void AddRequired(MultipartFormDataContent content, Stream data, string name, string filename)
{
if (data is null)
throw new CorrespondenceArgumentException($"Required value is missing: {name}");
content.Add(new StreamContent(data), name, filename);
}

internal static void AddIfNotNull(MultipartFormDataContent content, string? value, string name)
{
if (!string.IsNullOrWhiteSpace(value))
Expand Down Expand Up @@ -301,7 +308,7 @@ public sealed record CorrespondenceRequest : MultipartCorrespondenceItem
/// <summary>
/// Existing attachments that should be added to the correspondence.
/// </summary>
public IReadOnlyList<Guid>? ExistingAttachments { get; init; }
public IReadOnlyList<Guid>? ExistingAttachments { get; set; }

/// <summary>
/// Serialises the entire <see cref="CorrespondenceRequest"/> object to a provided <see cref="MultipartFormDataContent"/> instance.
Expand All @@ -321,7 +328,7 @@ internal void Serialise(MultipartFormDataContent content)
AddIfNotNull(content, IgnoreReservation?.ToString(), "Correspondence.IgnoreReservation");
AddIfNotNull(content, IsConfirmationNeeded?.ToString(), "Correspondence.IsConfirmationNeeded");
AddDictionaryItems(content, PropertyList, x => x, key => $"Correspondence.PropertyList.{key}");
AddListItems(content, ExistingAttachments, x => x.ToString(), i => $"Correspondence.ExistingAttachments[{i}]");
AddListItems(content, ExistingAttachments, x => x.ToString(), i => $"ExistingAttachments[{i}]");
AddListItems(content, Recipients, x => x.ToUrnFormattedString(), i => $"Recipients[{i}]");

Content.Serialise(content);
Expand Down
Loading
Loading