Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
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 NUnit.Framework;
using System;
using System.Threading;

namespace Frends.JSON.QuerySingle.UnitTests;

[TestFixture]
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
};

[Test]
public void Should_Throw_Error_When_ThrowErrorOnFailure_Is_True()
{
var ex = Assert.Throws<Exception>((TestDelegate)(() =>
JSON.QuerySingle(InvalidInput(), DefaultOptions(), CancellationToken.None)));
Assert.That(ex, Is.Not.Null);
}

[Test]
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.That(result.Success, Is.False);
Assert.That(result.Error, Is.Not.Null);
}

[Test]
public void Should_Use_Custom_ErrorMessageOnFailure()
{
var options = DefaultOptions();
options.ErrorMessageOnFailure = CustomErrorMessage;
var ex = Assert.Throws<Exception>((TestDelegate)(() =>
JSON.QuerySingle(InvalidInput(), options, CancellationToken.None)));
Assert.That(ex, Is.Not.Null);
Assert.That(ex?.Message, Does.Contain(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 All @@ -12,6 +12,8 @@
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" />
<PackageReference Include="MSTest.TestAdapter" Version="2.2.8" />
<PackageReference Include="MSTest.TestFramework" Version="2.2.8" />
<PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.2.1" />
<PackageReference Include="coverlet.collector" Version="3.1.2" />
</ItemGroup>

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,19 @@
using System;

namespace Frends.JSON.QuerySingle.Definitions;

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

/// <summary>
/// Additional information about the error (exception).
/// </summary>
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; }

/// <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></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,16 @@ 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>
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