Skip to content
7 changes: 7 additions & 0 deletions Frends.JSON.QuerySingle/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## [1.4.0] - 2026-08-05
### Updated
- Upgraded target framework from .NET 6 to .NET 8.
- Added `CancellationToken` support to the `QuerySingle` method.
- Added `ThrowErrorOnFailure` and `ErrorMessageOnFailure` options so that failures can be returned as a result instead of throwing an exception.
- The result now includes an `Error` property with error details when the task fails and `ThrowErrorOnFailure` is set to false.

## [1.3.0] - 2026-07-08
### Fixed
- Fixed issue where Options.ErrorWhenNotMatched did not throw an exception when a JSONPath filter expression (e.g. `[?(...)]`) matched no results.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
using Frends.JSON.QuerySingle.Definitions;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System;
using System.Threading;

namespace Frends.JSON.QuerySingle.UnitTests;

[TestClass]
public class ErrorHandlerTests
{
private const string CustomErrorMessage = "CustomErrorMessage";

private static Input InvalidInput() => new Input
{
Json = "not valid json",
Query = "$.key"
};

private static Options DefaultOptions() => new Options
{
ErrorWhenNotMatched = false,
ThrowErrorOnFailure = true,
ErrorMessageOnFailure = string.Empty
};

[TestMethod]
public void Should_Throw_Error_When_ThrowErrorOnFailure_Is_True()
{
var ex = Assert.ThrowsException<Exception>((Action)(() =>
JSON.QuerySingle(InvalidInput(), DefaultOptions(), CancellationToken.None)));
Assert.IsNotNull(ex);
}

[TestMethod]
public void Should_Return_Failed_Result_When_ThrowErrorOnFailure_Is_False()
{
var options = DefaultOptions();
options.ThrowErrorOnFailure = false;
var result = JSON.QuerySingle(InvalidInput(), options, CancellationToken.None);
Assert.IsFalse(result.Success);
Assert.IsNotNull(result.Error);
}

[TestMethod]
public void Should_Use_Custom_ErrorMessageOnFailure()
{
var options = DefaultOptions();
options.ErrorMessageOnFailure = CustomErrorMessage;
var ex = Assert.ThrowsException<Exception>((Action)(() =>
JSON.QuerySingle(InvalidInput(), options, CancellationToken.None)));
Assert.IsNotNull(ex);
Assert.IsTrue(ex.Message.Contains(CustomErrorMessage));
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Threading;

namespace Frends.JSON.QuerySingle.UnitTests;

Expand Down Expand Up @@ -54,7 +55,7 @@ public void TestQuerySingle()
ErrorWhenNotMatched = true,
};

var result = JSON.QuerySingle(input, options);
var result = JSON.QuerySingle(input, options, CancellationToken.None);
Assert.IsTrue(result.Success);
Assert.IsInstanceOfType(result.Data, typeof(JObject));
}
Expand All @@ -73,8 +74,8 @@ public void QueryShouldThrowIfOptionSetAndNothingIsFound()
ErrorWhenNotMatched = true,
};

var ex = Assert.ThrowsException<JsonException>(() => JSON.QuerySingle(input, options));
Assert.IsTrue(ex.Message.Contains("Property 'Manufacturer' does not exist on JObject."));
var ex = Assert.ThrowsException<Exception>(() => JSON.QuerySingle(input, options, CancellationToken.None));
Assert.IsTrue(ex.Message.Contains("Property 'Manufacturer' does not exist on JObject.") || (ex.InnerException != null && ex.InnerException.Message.Contains("Property 'Manufacturer' does not exist on JObject.")));
}

[TestMethod]
Expand All @@ -91,7 +92,7 @@ public void QuerySingleShouldThrowIfOptionSetAndFilterMatchesNothing()
ErrorWhenNotMatched = true,
};

Assert.ThrowsException<JsonException>(() => JSON.QuerySingle(input, options));
Assert.ThrowsException<Exception>(() => JSON.QuerySingle(input, options, CancellationToken.None));
}

[TestMethod]
Expand All @@ -108,7 +109,7 @@ public void QuerySingleShouldNotThrowIfOptionNotSetAndNothingIsFound()
ErrorWhenNotMatched = false,
};

var result = JSON.QuerySingle(input, options);
var result = JSON.QuerySingle(input, options, CancellationToken.None);
Assert.IsTrue(result.Success);
Assert.IsNull(result.Data);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using System;

namespace Frends.JSON.QuerySingle.Definitions;

/// <summary>
/// Error details.
/// </summary>
public class Error
{
/// <summary>
/// Error message.
/// </summary>
/// <example>Query failed unexpectedly.</example>
public string Message { get; set; }

/// <summary>
/// Additional information about the error (exception).
/// </summary>
/// <example>object { Exception AdditionalInfo }</example>
public Exception AdditionalInfo { get; set; }
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
namespace Frends.JSON.QuerySingle.Definitions;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;

namespace Frends.JSON.QuerySingle.Definitions;

/// <summary>
/// Options parameters.
Expand All @@ -10,4 +13,19 @@ public class Options
/// </summary>
/// <example>true</example>
public bool ErrorWhenNotMatched { get; set; }
Comment thread
MatteoDelOmbra marked this conversation as resolved.
}

/// <summary>
/// Throw an exception if the task fails.
/// </summary>
/// <example>true</example>
[DefaultValue(true)]
public bool ThrowErrorOnFailure { get; set; } = true;

/// <summary>
/// Custom error message to include when the task fails. Leave empty to use the default error message.
/// </summary>
/// <example>Custom error message</example>
[DisplayFormat(DataFormatString = "Text")]
[DefaultValue("")]
public string ErrorMessageOnFailure { get; set; } = string.Empty;
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,17 @@ public class Result
/// Operation complete without errors.
/// </summary>
/// <example>true</example>
public bool Success { get; private set; }
public bool Success { get; set; }

/// <summary>
/// Result data.
/// </summary>
/// <example>{{ "Name": "Foo", "Products": [{ "Name": "Bar", "Price": 1 }]}}</example>
public dynamic Data { get; private set; }
public dynamic Data { get; set; }

internal Result(bool success, object data)
{
Success = success;
Data = data;
}
}
/// <summary>
/// Error information when the task fails and ThrowErrorOnFailure is false.
/// </summary>
/// <example>object { string Message = "Query failed unexpectedly.", Exception AdditionalInfo }</example>
public Error Error { get; set; }
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFrameworks>net6.0</TargetFrameworks>
<Version>1.3.0</Version>
<TargetFrameworks>net8.0</TargetFrameworks>
<Version>1.4.0</Version>
<Authors>Frends</Authors>
<Copyright>Frends</Copyright>
<Company>Frends</Company>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using Frends.JSON.QuerySingle.Definitions;
using System;

namespace Frends.JSON.QuerySingle.Helpers;

internal static class ErrorHandler
{
internal static Result Handle(this Exception exception, Options options, bool throwCanceled = true)
{
ThrowIfCanceled(exception, throwCanceled);
if (options.ThrowErrorOnFailure) ThrowBaseException(exception, options.ErrorMessageOnFailure);

return ReturnResult(exception, options.ErrorMessageOnFailure);
}

private static void ThrowIfCanceled(Exception exception, bool throwCanceled = true)
{
if (throwCanceled && exception is OperationCanceledException) throw exception;
}

private static void ThrowBaseException(Exception exception, string customMessage = null)
{
if (string.IsNullOrEmpty(customMessage))
throw new Exception(exception.Message, exception);

throw new Exception(customMessage, exception);
}

private static Result ReturnResult(Exception exception, string customMessage = null)
{
var errorMessage = string.IsNullOrEmpty(customMessage)
? exception.Message
: $"{customMessage}: {exception.Message}";

return new Result
{
Success = false,
Error = new Error
{
Message = errorMessage,
AdditionalInfo = exception,
}
};
}
}
28 changes: 20 additions & 8 deletions Frends.JSON.QuerySingle/Frends.JSON.QuerySingle/QuerySingle.cs
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
using Frends.JSON.QuerySingle.Definitions;
using Frends.JSON.QuerySingle.Helpers;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System;
using System.ComponentModel;
using System.IO;
using System.Reflection;
using System.Runtime.Loader;
using System.Threading;

namespace Frends.JSON.QuerySingle;

/// <summary>
/// JSON Task.
/// </summary>
public class JSON
public static class JSON
{
/// Mem cleanup.
static JSON()
Expand All @@ -28,16 +31,25 @@ static JSON()
/// </summary>
/// <param name="input">Input parameters.</param>
/// <param name="options">Optional parameters.</param>
/// <returns>Object { bool Success, dynamic Data }</returns>
public static Result QuerySingle([PropertyTab] Input input, [PropertyTab] Options options)
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Object { bool Success, dynamic Data, Error Error }</returns>
public static Result QuerySingle([PropertyTab] Input input, [PropertyTab] Options options, CancellationToken cancellationToken)
{
JToken jToken = GetJTokenFromInput(input.Json);
JToken result = jToken.SelectToken(input.Query, options.ErrorWhenNotMatched);
try
{
cancellationToken.ThrowIfCancellationRequested();
JToken jToken = GetJTokenFromInput(input.Json);
JToken result = jToken.SelectToken(input.Query, options.ErrorWhenNotMatched);

if (result == null && options.ErrorWhenNotMatched)
throw new JsonException($"No matches found for query '{input.Query}'.");
if (result == null && options.ErrorWhenNotMatched)
throw new JsonException($"No matches found for query '{input.Query}'.");

return new Result(true, result);
return new Result { Success = true, Data = result };
}
catch (Exception ex)
{
return ex.Handle(options);
}
}

private static object GetJTokenFromInput(dynamic json)
Expand Down
Loading