// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// 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.
using Newtonsoft.Json;
using System;
using System.IO;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using HttpMethod = System.Net.Http.HttpMethod;
namespace Duplicati.Library;
///
/// Minimalist version of the JSONWebHelper to be used with HttpClient HttpRequestMessage/HttpResponseMessage types
///
/// HttpClient reference
public class JsonWebHelperHttpClient(HttpClient httpClient)
{
///
/// HttpClient reference for inheritors
///
protected readonly HttpClient _httpClient = httpClient;
///
/// Useragent string building method
///
protected string UserAgent => $"Duplicati v{System.Reflection.Assembly.GetExecutingAssembly().GetName().Version}";
public event Action CreateSetupHelper;
///
/// Centralized method to prepare a request with the given URL and method setting useragent
///
/// Url
/// Method
public virtual HttpRequestMessage CreateRequest(string url, string method = null)
{
HttpRequestMessage request = new HttpRequestMessage(string.IsNullOrEmpty(method) ? HttpMethod.Get : new HttpMethod(method), url);
request.Headers.Add("User-Agent", UserAgent);
CreateSetupHelper?.Invoke(request);
return request;
}
///
/// Performs a multipart post and parses the response as JSON
///
/// The parsed JSON item.
/// The url to post to.
/// Token to cancel the operation.
/// The multipart items.
/// The return type parameter.
public virtual async Task PostMultipartAndGetJsonDataAsync(string url, CancellationToken cancellationToken, MultipartContent parts)
{
using var response = await PostMultipartAsync(url, cancellationToken, parts).ConfigureAwait(false);
return ReadJsonResponse(response);
}
///
/// Performs a multipart post
///
/// The response.
/// The url to post to.
/// Token to cancel the operation.
/// The multipart items.
protected virtual async Task PostMultipartAsync(string url, CancellationToken cancellationToken, MultipartContent parts)
{
using var req = CreateRequest(url, "POST");
req.Content = parts;
return await _httpClient.SendAsync(req, cancellationToken).ConfigureAwait(false);
}
///
/// Execute Get request and return response and deserializes JSON response into the given type
///
/// Url
/// Cancellation Token
/// Setup Actions for customizing the request
/// Destination Type
protected virtual async Task GetJsonDataAsync(string url, CancellationToken cancellationToken, Action setup = null)
{
using var req = CreateRequest(url);
if (setup != null)
setup(req);
return await ReadJsonResponseAsync(req, cancellationToken).ConfigureAwait(false);
}
///
/// Execute Post and return response and deserializes JSON response into the given type
///
/// Url
/// Cancellation Token
/// The item to be serialized into a json and added to the body
/// Destination Type
public virtual async Task PostAndGetJsonDataAsync(string url, object item, CancellationToken cancellationToken)
{
var data = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(item));
return await GetJsonDataAsync(
url,
cancellationToken,
request =>
{
request.Method = HttpMethod.Post;
request.Content = new ByteArrayContent(data);
request.Content.Headers.Add("Content-Length", data.Length.ToString());
request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
}
).ConfigureAwait(false);
}
///
/// Executes a web request and json-deserializes the results as the specified type
///
/// The deserialized JSON data.
/// The remote URL
/// Token to cancel the operation.
/// The type of data to return.
public virtual async Task GetJsonDataAsync(string url, CancellationToken cancellationToken)
{
return await GetJsonDataAsync(
url,
cancellationToken,
request =>
{
request.Method = HttpMethod.Get;
}
).ConfigureAwait(false);
}
///
/// Reads the JSON response from the server and deserializes it into the given type
///
/// Request object
/// Cancellation Token
/// Destination Type
protected virtual async Task ReadJsonResponseAsync(HttpRequestMessage req, CancellationToken cancellationToken)
{
using var resp = await GetResponseAsync(req, cancellationToken).ConfigureAwait(false);
return await ReadJsonResponseAsync(resp, cancellationToken).ConfigureAwait(false);
}
///
/// Read the JSON response from the server and deserialize it into the given type
///
/// Response object
/// Type to cast to
///
/// Exception when failing to deserialize the JSON to the Type
protected virtual T ReadJsonResponse(HttpResponseMessage response)
{
using var rs = response.Content.ReadAsStream();
using var ps = new StreamPeekReader(rs);
try
{
using var tr = new StreamReader(ps);
using var jr = new JsonTextReader(tr);
return new JsonSerializer().Deserialize(jr);
}
catch (Exception ex)
{
// If we get invalid JSON, report the peek value
if (ex is JsonReaderException)
throw new IOException($"Invalid JSON data: \"{ps.PeekData()}\"", ex);
// Otherwise, we have no additional help to offer
throw;
}
}
///
/// Read the JSON response from the server and deserialize it into the given type asynchronously
///
/// Response object
///
/// Type to cast to
///
/// Exception when failing to deserialize the JSON to the Type
protected virtual async Task ReadJsonResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken)
{
await using var rs = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
await using var ps = new StreamPeekReader(rs);
try
{
using var tr = new StreamReader(ps);
await using var jr = new JsonTextReader(tr);
return new JsonSerializer().Deserialize(jr);
}
catch (Exception ex)
{
// If we get invalid JSON, report the peek value
if (ex is JsonReaderException)
throw new IOException($"Invalid JSON data: \"{ps.PeekData()}\"", ex);
// Otherwise, we have no additional help to offer
throw;
}
}
///
/// Use this method to register an exception handler,
/// which can throw another, more meaningful exception
///
public virtual async Task AttemptParseAndThrowExceptionAsync(Exception ex, HttpResponseMessage responseContext = null, CancellationToken cancellationToken = default)
{
}
///
/// Use this method to register an exception handler,
/// which can throw another, more meaningful exception
///
public virtual void AttemptParseAndThrowException(Exception ex, HttpResponseMessage responseContext = null)
{
}
///
/// Execute request and return response
///
/// Request object
/// Cancellation Token
///
public async Task GetResponseAsync(HttpRequestMessage req, CancellationToken cancellationToken)
{
HttpResponseMessage response = null;
try
{
response = await _httpClient.SendAsync(req, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return response;
}
catch (Exception ex)
{
AttemptParseAndThrowException(ex, response);
throw;
}
}
///
/// A utility class that shadows the real stream but provides access
/// to the first 2kb of the stream to use in error reporting
///
private class StreamPeekReader(Stream source) : Stream
{
private readonly byte[] m_peekbuffer = new byte[1024 * 2];
private int m_peekbytes = 0;
public string PeekData()
{
if (m_peekbuffer.Length == 0)
return string.Empty;
return Encoding.UTF8.GetString(m_peekbuffer, 0, m_peekbytes);
}
public override bool CanRead => source.CanRead;
public override bool CanSeek => source.CanSeek;
public override bool CanWrite => source.CanWrite;
public override long Length => source.Length;
public override long Position { get => source.Position; set => source.Position = value; }
public override void Flush() => source.Flush();
public override long Seek(long offset, SeekOrigin origin) => source.Seek(offset, origin);
public override void SetLength(long value) => source.SetLength(value);
public override void Write(byte[] buffer, int offset, int count) => source.Write(buffer, offset, count);
public override IAsyncResult BeginRead(byte[] buffer, int offset, int count, AsyncCallback callback, object state) => source.BeginRead(buffer, offset, count, callback, state);
public override IAsyncResult BeginWrite(byte[] buffer, int offset, int count, AsyncCallback callback, object state) => source.BeginWrite(buffer, offset, count, callback, state);
public override bool CanTimeout => source.CanTimeout;
public override void Close() => source.Close();
public override Task CopyToAsync(Stream destination, int bufferSize, CancellationToken cancellationToken) => source.CopyToAsync(destination, bufferSize, cancellationToken);
protected override void Dispose(bool disposing) => base.Dispose(disposing);
public override int EndRead(IAsyncResult asyncResult) => source.EndRead(asyncResult);
public override void EndWrite(IAsyncResult asyncResult) => source.EndWrite(asyncResult);
public override Task FlushAsync(CancellationToken cancellationToken) => source.FlushAsync(cancellationToken);
public override int ReadTimeout { get => source.ReadTimeout; set => source.ReadTimeout = value; }
public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => source.WriteAsync(buffer, offset, count, cancellationToken);
public override int WriteTimeout { get => source.WriteTimeout; set => source.WriteTimeout = value; }
public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
var br = 0;
if (m_peekbytes < m_peekbuffer.Length - 1)
{
var maxb = Math.Min(count, m_peekbuffer.Length - m_peekbytes);
br = await source.ReadAsync(m_peekbuffer, m_peekbytes, maxb, cancellationToken);
Array.Copy(m_peekbuffer, m_peekbytes, buffer, offset, br);
m_peekbytes += br;
offset += br;
count -= br;
if (count == 0 || br < maxb)
return br;
}
return await source.ReadAsync(buffer, offset, count, cancellationToken) + br;
}
public override int Read(byte[] buffer, int offset, int count)
{
var br = 0;
if (m_peekbytes < m_peekbuffer.Length - 1)
{
var maxb = Math.Min(count, m_peekbuffer.Length - m_peekbytes);
br = source.Read(m_peekbuffer, m_peekbytes, maxb);
Array.Copy(m_peekbuffer, m_peekbytes, buffer, offset, br);
m_peekbytes += br;
offset += br;
count -= br;
if (count == 0 || br < maxb)
return br;
}
return source.Read(buffer, offset, count) + br;
}
}
}