/* ========================================================================
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Buffers;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace Opc.Ua.WotCon.Client
{
///
/// Extension methods that add System.IO-style upload / download
/// helpers on top of the generated
/// proxy. These keep the WoT Connectivity client free of any
/// inheritance over the existing Opc.Ua.Client.FileSystem
/// types — every wire interaction goes through the auto-generated
/// Open/Read/Write/Close method
/// wrappers.
///
public static class FileTypeClientExtensions
{
///
/// Default per-call chunk size used by upload / download when
/// the server does not advertise a smaller
/// MaxByteStringLength.
///
public const int DefaultChunkSize = 4096;
///
/// Uploads to the file behind
/// in chunks. The file is opened with
/// Write | EraseExisting (the only write mode supported
/// by some servers, including WoT Connectivity), written in
/// pieces of at most bytes, and
/// then closed.
///
/// The FileType proxy.
/// The bytes to upload.
/// The Open mode to use; defaults to
/// Write | EraseExisting.
/// Maximum per-write chunk size.
/// Cancellation token.
/// is null.
/// is less than or equal to zero.
public static async ValueTask UploadAsync(
this FileTypeClient file,
ReadOnlyMemory content,
byte mode = 6,
int chunkSize = DefaultChunkSize,
CancellationToken ct = default)
{
if (file is null)
{
throw new ArgumentNullException(nameof(file));
}
if (chunkSize <= 0)
{
throw new ArgumentOutOfRangeException(nameof(chunkSize), "Chunk size must be positive.");
}
uint handle = await file.OpenAsync(mode, ct).ConfigureAwait(false);
try
{
int offset = 0;
while (offset < content.Length)
{
int take = Math.Min(chunkSize, content.Length - offset);
var chunk = ByteString.From(content.Slice(offset, take).ToArray());
await file.WriteAsync(handle, chunk, ct).ConfigureAwait(false);
offset += take;
}
}
finally
{
await file.CloseAsync(handle, CancellationToken.None).ConfigureAwait(false);
}
}
///
/// Uploads the contents of to the
/// file behind in chunks. The stream is
/// read sequentially until end-of-stream; non-seekable streams
/// (e.g. ,
/// ) are
/// supported. The caller retains ownership of
/// and is responsible for disposing
/// it.
///
/// The FileType proxy.
/// A readable stream producing the bytes
/// to upload.
/// The Open mode to use; defaults to
/// Write | EraseExisting.
/// Maximum per-write chunk size and
/// size of the rented intermediate buffer.
/// Cancellation token.
/// is null.
/// is null.
/// is not readable.
/// is less than or equal to zero.
public static async ValueTask UploadAsync(
this FileTypeClient file,
Stream content,
byte mode = 6,
int chunkSize = DefaultChunkSize,
CancellationToken ct = default)
{
if (file is null)
{
throw new ArgumentNullException(nameof(file));
}
if (content is null)
{
throw new ArgumentNullException(nameof(content));
}
if (!content.CanRead)
{
throw new ArgumentException("Stream must be readable.", nameof(content));
}
if (chunkSize <= 0)
{
throw new ArgumentOutOfRangeException(nameof(chunkSize), "Chunk size must be positive.");
}
uint handle = await file.OpenAsync(mode, ct).ConfigureAwait(false);
try
{
await CopyStreamInChunksAsync(
content,
chunkSize,
(chunk, token) => file.WriteAsync(handle, ByteString.From(chunk), token),
ct).ConfigureAwait(false);
}
finally
{
await file.CloseAsync(handle, CancellationToken.None).ConfigureAwait(false);
}
}
///
/// Reads the entire content of the file behind
/// into memory using chunked Read calls.
///
/// is null.
/// is less than or equal to zero.
public static async ValueTask DownloadAllAsync(
this FileTypeClient file,
int chunkSize = DefaultChunkSize,
CancellationToken ct = default)
{
if (file is null)
{
throw new ArgumentNullException(nameof(file));
}
if (chunkSize <= 0)
{
throw new ArgumentOutOfRangeException(nameof(chunkSize), "Chunk size must be positive.");
}
const byte readMode = 1;
uint handle = await file.OpenAsync(readMode, ct).ConfigureAwait(false);
try
{
using MemoryStream buffer = new();
while (true)
{
ByteString chunk = await file.ReadAsync(handle, chunkSize, ct).ConfigureAwait(false);
if (chunk.IsNull || chunk.Span.Length == 0)
{
break;
}
byte[] copy = chunk.Span.ToArray();
buffer.Write(copy, 0, copy.Length);
if (chunk.Span.Length < chunkSize)
{
break;
}
}
return buffer.ToArray();
}
finally
{
await file.CloseAsync(handle, CancellationToken.None).ConfigureAwait(false);
}
}
///
/// Reads the entire content of the file behind
/// using chunked Read calls and
/// writes it sequentially into .
/// The caller retains ownership of
/// and is responsible for
/// disposing it.
///
/// The FileType proxy.
/// A writable stream that receives
/// the file contents.
/// Maximum per-read chunk size.
/// Cancellation token.
/// is null.
/// is null.
/// is not writable.
/// is less than or equal to zero.
public static async ValueTask DownloadToAsync(
this FileTypeClient file,
Stream destination,
int chunkSize = DefaultChunkSize,
CancellationToken ct = default)
{
if (file is null)
{
throw new ArgumentNullException(nameof(file));
}
if (destination is null)
{
throw new ArgumentNullException(nameof(destination));
}
if (!destination.CanWrite)
{
throw new ArgumentException("Stream must be writable.", nameof(destination));
}
if (chunkSize <= 0)
{
throw new ArgumentOutOfRangeException(nameof(chunkSize), "Chunk size must be positive.");
}
const byte readMode = 1;
uint handle = await file.OpenAsync(readMode, ct).ConfigureAwait(false);
try
{
await CopyChunksToStreamAsync(
destination,
chunkSize,
async (size, token) =>
{
ByteString chunk = await file.ReadAsync(handle, size, token).ConfigureAwait(false);
if (chunk.IsNull)
{
return ReadOnlyMemory.Empty;
}
return chunk.Span.ToArray();
},
ct).ConfigureAwait(false);
}
finally
{
await file.CloseAsync(handle, CancellationToken.None).ConfigureAwait(false);
}
}
///
/// Reads from in chunks of up to
/// bytes and invokes
/// for each chunk that contained
/// data. The loop terminates when
///
/// returns 0 (end-of-stream). The buffer is rented from
/// .
///
internal static async ValueTask CopyStreamInChunksAsync(
this Stream source,
int chunkSize,
Func, CancellationToken, ValueTask> writeChunk,
CancellationToken ct)
{
byte[] buffer = ArrayPool.Shared.Rent(chunkSize);
try
{
while (true)
{
ct.ThrowIfCancellationRequested();
#if NETSTANDARD2_1_OR_GREATER || NET
int read = await source.ReadAsync(buffer.AsMemory(0, chunkSize), ct)
.ConfigureAwait(false);
#else
int read = await source.ReadAsync(buffer, 0, chunkSize, ct)
.ConfigureAwait(false);
#endif
if (read <= 0)
{
break;
}
await writeChunk(buffer.AsMemory(0, read), ct).ConfigureAwait(false);
}
}
finally
{
ArrayPool.Shared.Return(buffer);
}
}
///
/// Repeatedly invokes until it
/// returns an empty chunk (end-of-file) or a partial chunk
/// shorter than (which signals the
/// last chunk for a chunk-aligned server protocol like
/// FileType.Read) and writes each chunk sequentially
/// into .
///
internal static async ValueTask CopyChunksToStreamAsync(
this Stream destination,
int chunkSize,
Func>> readChunk,
CancellationToken ct)
{
while (true)
{
ct.ThrowIfCancellationRequested();
ReadOnlyMemory chunk = await readChunk(chunkSize, ct).ConfigureAwait(false);
if (chunk.IsEmpty)
{
break;
}
#if NETSTANDARD2_1_OR_GREATER || NET
await destination.WriteAsync(chunk, ct).ConfigureAwait(false);
#else
byte[] copy = chunk.ToArray();
await destination.WriteAsync(copy, 0, copy.Length, ct).ConfigureAwait(false);
#endif
if (chunk.Length < chunkSize)
{
break;
}
}
}
}
}