Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 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
29a992a
Revert "Remove usings"
Ceredron Aug 7, 2025
d41e3f5
Revert "New experiment"
Ceredron Aug 7, 2025
49cac69
Disposals, update integration test snapshots
martinothamar Aug 7, 2025
1561d72
Merge branch 'main' into feat/streamed-correspondence-upload
martinothamar Aug 7, 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,
Data = _data.Value,

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.
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
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,11 @@ CancellationToken cancellationToken
)
{
using HttpClient client = _httpClientFactory.CreateClient();

// Configure HttpClient for large file uploads
client.Timeout = TimeSpan.FromMinutes(30);
client.DefaultRequestHeaders.ExpectContinue = false;

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
@@ -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 @@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
namespace Altinn.App.Core.Features.Correspondence.Models;

/// <summary>
/// Represents an attachment to a correspondence with streaming data support.
/// Inherits from CorrespondenceAttachment and provides a Stream-based data property.
/// 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>
public record CorrespondenceStreamedAttachment : CorrespondenceAttachment
{
/// <summary>
/// The data content as a stream.
/// 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>
public required Stream 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");
}
}
114 changes: 114 additions & 0 deletions src/Altinn.App.Core/Helpers/ResponseWrapperStream.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
namespace Altinn.App.Core.Helpers;

/// <summary>
/// A wrapper stream that ensures proper disposal of an HttpResponseMessage along with its content stream.
/// </summary>
internal sealed class ResponseWrapperStream : Stream
{
private readonly HttpResponseMessage _response;
private readonly Stream _innerStream;

/// <summary>
/// Initializes a new instance of the <see cref="ResponseWrapperStream"/> class.
/// </summary>
/// <param name="response">The HTTP response message to be disposed when the stream is disposed.</param>
/// <param name="innerStream">The inner stream to wrap and delegate operations to.</param>
public ResponseWrapperStream(HttpResponseMessage response, Stream innerStream)
{
_response = response;
_innerStream = innerStream;
}

/// <summary>
/// Releases the unmanaged resources used by the <see cref="ResponseWrapperStream"/> and optionally releases the managed resources.
/// </summary>
/// <param name="disposing">true to release both managed and unmanaged resources; false to release only unmanaged resources.</param>
protected override void Dispose(bool disposing)
{
if (disposing)
{
_response?.Dispose(); // This will also dispose the inner stream
}
base.Dispose(disposing);
}

// Delegate all Stream operations to _innerStream

/// <summary>
/// Gets a value indicating whether the current stream supports reading.
/// </summary>
public override bool CanRead => _innerStream.CanRead;

/// <summary>
/// Gets a value indicating whether the current stream supports seeking.
/// </summary>
public override bool CanSeek => _innerStream.CanSeek;

/// <summary>
/// Gets a value indicating whether the current stream supports writing.
/// </summary>
public override bool CanWrite => _innerStream.CanWrite;

/// <summary>
/// Gets the length in bytes of the stream.
/// </summary>
/// <exception cref="NotSupportedException">The stream does not support seeking.</exception>
public override long Length => _innerStream.Length;

/// <summary>
/// Gets or sets the position within the current stream.
/// </summary>
/// <exception cref="NotSupportedException">The stream does not support seeking.</exception>
public override long Position
{
get => _innerStream.Position;
set => _innerStream.Position = value;
}

/// <summary>
/// Clears all buffers for this stream and causes any buffered data to be written to the underlying device.
/// </summary>
public override void Flush() => _innerStream.Flush();

/// <summary>
/// Reads a sequence of bytes from the current stream and advances the position within the stream by the number of bytes read.
/// </summary>
/// <param name="buffer">An array of bytes. When this method returns, the buffer contains the specified byte array with the values between offset and (offset + count - 1) replaced by the bytes read from the current source.</param>
/// <param name="offset">The zero-based byte offset in buffer at which to begin storing the data read from the current stream.</param>
/// <param name="count">The maximum number of bytes to be read from the current stream.</param>
/// <returns>The total number of bytes read into the buffer. This can be less than the number of bytes requested if that many bytes are not currently available, or zero (0) if the end of the stream has been reached.</returns>
/// <exception cref="ArgumentNullException">buffer is null.</exception>
/// <exception cref="ArgumentOutOfRangeException">offset or count is negative.</exception>
/// <exception cref="ArgumentException">The sum of offset and count is larger than the buffer length.</exception>
/// <exception cref="NotSupportedException">The stream does not support reading.</exception>
public override int Read(byte[] buffer, int offset, int count) => _innerStream.Read(buffer, offset, count);

/// <summary>
/// Sets the position within the current stream.
/// </summary>
/// <param name="offset">A byte offset relative to the origin parameter.</param>
/// <param name="origin">A value of type <see cref="SeekOrigin"/> indicating the reference point used to obtain the new position.</param>
/// <returns>The new position within the current stream.</returns>
/// <exception cref="NotSupportedException">The stream does not support seeking.</exception>
public override long Seek(long offset, SeekOrigin origin) => _innerStream.Seek(offset, origin);

/// <summary>
/// Sets the length of the current stream.
/// </summary>
/// <param name="value">The desired length of the current stream in bytes.</param>
/// <exception cref="NotSupportedException">The stream does not support both writing and seeking.</exception>
/// <exception cref="ArgumentOutOfRangeException">value is negative.</exception>
public override void SetLength(long value) => _innerStream.SetLength(value);

/// <summary>
/// Writes a sequence of bytes to the current stream and advances the current position within this stream by the number of bytes written.
/// </summary>
/// <param name="buffer">An array of bytes. This method copies count bytes from buffer to the current stream.</param>
/// <param name="offset">The zero-based byte offset in buffer at which to begin copying bytes to the current stream.</param>
/// <param name="count">The number of bytes to be written to the current stream.</param>
/// <exception cref="ArgumentNullException">buffer is null.</exception>
/// <exception cref="ArgumentOutOfRangeException">offset or count is negative.</exception>
/// <exception cref="ArgumentException">The sum of offset and count is greater than the buffer length.</exception>
/// <exception cref="NotSupportedException">The stream does not support writing.</exception>
public override void Write(byte[] buffer, int offset, int count) => _innerStream.Write(buffer, offset, count);
}
15 changes: 13 additions & 2 deletions src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -179,14 +179,25 @@ Guid dataId

string token = _userTokenProvider.GetUserToken();

HttpResponseMessage response = await _client.GetAsync(token, apiUrl);
HttpResponseMessage response = await _client.GetStreamingAsync(token, apiUrl);

if (response.IsSuccessStatusCode)
{
return await response.Content.ReadAsStreamAsync();
Stream? stream = null;
try
{
stream = await response.Content.ReadAsStreamAsync();
return new ResponseWrapperStream(response, stream);
}
catch (Exception)
{
response.Dispose();
throw;
}
}
else if (response.StatusCode == HttpStatusCode.NotFound)
{
response.Dispose();
#nullable disable
return null;
#nullable restore
Expand Down
Loading
Loading