Skip to content

Commit

Permalink
[POC] Denote the interdependency between parameters in a parameter set (
Browse files Browse the repository at this point in the history
#571)

- Add new InvalidParameterPairError exception type
- Add new top-level and nested parameter attributes to denote interdependency between multiple parameters
- Check interdependency of parameters during serialization
- Test parameter interdependency enforcement
- Test invalid parameter pair exception
- Shipment and Batch parameter for a Pickup.Create parameter set are interdependent (mutually exclusive)
  • Loading branch information
nwithan8 committed Jun 12, 2024
1 parent e5b2f3a commit 902336c
Show file tree
Hide file tree
Showing 9 changed files with 428 additions and 22 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# CHANGELOG

## Next Release

- Enforce one-or-other for `Shipment` and `Batch` parameters in `Pickup.Create` parameter set
- Add internal parameter dependency utility

## v6.5.2 (2024-06-12)

- Fix `Shipment` parameter requirement for `Pickup.Create` parameter set
Expand Down
4 changes: 4 additions & 0 deletions EasyPost.Tests/ExceptionsTests/ExceptionsTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ public void TestExceptionConstructors()
{
const string testMessage = "This is a test message.";
const string testPropertyName = "test_property";
const string testPropertyName2 = "test_property2";
Type testType = typeof(ExceptionsTests);

// Test the base EasyPostError constructor
Expand Down Expand Up @@ -170,6 +171,9 @@ public void TestExceptionConstructors()
InvalidParameterError invalidParameterError = new(testPropertyName);
Assert.Equal($"{string.Format(CultureInfo.InvariantCulture, Constants.ErrorMessages.InvalidParameter, testPropertyName)}. ", invalidParameterError.Message);

InvalidParameterPairError invalidParameterPairError = new(testPropertyName, testPropertyName2);
Assert.Equal($"{string.Format(CultureInfo.InvariantCulture, Constants.ErrorMessages.InvalidParameterPair, testPropertyName, testPropertyName2)}. ", invalidParameterPairError.Message);

JsonDeserializationError jsonDeserializationError = new(testType);
Assert.Equal(string.Format(CultureInfo.InvariantCulture, Constants.ErrorMessages.JsonDeserializationError, testType.FullName), jsonDeserializationError.Message);

Expand Down
229 changes: 208 additions & 21 deletions EasyPost.Tests/ParametersTests/ParametersTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -225,37 +225,157 @@ public void TestRequiredAndOptionalParameterValidation()
Assert.Throws<Exceptions.General.MissingParameterError>(() => parametersWithOnlyOptionalParameterSet.ToDictionary());
}

private sealed class ParameterSetWithRequiredAndOptionalParameters : Parameters.BaseParameters<EasyPostObject>
[Fact]
[Testing.Exception]
public void TestOneOrOtherDependentTopLevelParameters()
{
[TopLevelRequestParameter(Necessity.Required, "test", "required")]
// ReSharper disable once UnusedAutoPropertyAccessor.Local
public string? RequiredParameter { get; set; }
// Either A or B must be set, but not both.

// Should throw exception if both set.
var parametersWithOneOrOtherInterdependenceBothSet = new ParameterSetWithOneOrOtherDependentTopLevelParameters
{
AParam = "A",
BParam = "B",
};

Assert.Throws<Exceptions.General.InvalidParameterPairError>(() => parametersWithOneOrOtherInterdependenceBothSet.ToDictionary());

// Should throw exception if neither set.
var parametersWithOneOrOtherInterdependenceNeitherSet = new ParameterSetWithOneOrOtherDependentTopLevelParameters();

Assert.Throws<Exceptions.General.InvalidParameterPairError>(() => parametersWithOneOrOtherInterdependenceNeitherSet.ToDictionary());

// Should not throw exception if only A is set.
var parametersWithOneOrOtherInterdependenceOnlyASet = new ParameterSetWithOneOrOtherDependentTopLevelParameters
{
AParam = "A",
};

try
{
parametersWithOneOrOtherInterdependenceOnlyASet.ToDictionary();
}
catch (Exceptions.General.InvalidParameterPairError)
{
Assert.Fail("Should not throw exception if only A is set.");
}

// Should not throw exception if only B is set.
var parametersWithOneOrOtherInterdependenceOnlyBSet = new ParameterSetWithOneOrOtherDependentTopLevelParameters
{
BParam = "B",
};

[TopLevelRequestParameter(Necessity.Optional, "test", "optional")]
// ReSharper disable once UnusedAutoPropertyAccessor.Local
public string? OptionalParameter { get; set; }
try
{
parametersWithOneOrOtherInterdependenceOnlyBSet.ToDictionary();
}
catch (Exceptions.General.InvalidParameterPairError)
{
Assert.Fail("Should not throw exception if only B is set.");
}
}

private sealed class ParameterSetWithCompetingParameters : Parameters.BaseParameters<EasyPostObject>
[Fact]
[Testing.Exception]
public void TestBothOrNeitherDependentTopLevelParameters()
{
[TopLevelRequestParameter(Necessity.Optional, "location")]
// ReSharper disable once UnusedAutoPropertyAccessor.Local
public string? AParam { get; set; }
// Either both A and B must be set, or neither.

// Should throw exception if only A is set.
var parametersWithBothOrNeitherInterdependenceOnlyASet = new ParameterSetWithBothOrNeitherDependentTopLevelParameters
{
AParam = "A",
};

Assert.Throws<Exceptions.General.InvalidParameterPairError>(() => parametersWithBothOrNeitherInterdependenceOnlyASet.ToDictionary());

[TopLevelRequestParameter(Necessity.Optional, "location")]
// ReSharper disable once UnusedAutoPropertyAccessor.Local
public string? BParam { get; set; }
// Should throw exception if only B is set.
var parametersWithBothOrNeitherInterdependenceOnlyBSet = new ParameterSetWithBothOrNeitherDependentTopLevelParameters
{
BParam = "B",
};

Assert.Throws<Exceptions.General.InvalidParameterPairError>(() => parametersWithBothOrNeitherInterdependenceOnlyBSet.ToDictionary());

// Should not throw exception if both A and B are set.
var parametersWithBothOrNeitherInterdependenceBothSet = new ParameterSetWithBothOrNeitherDependentTopLevelParameters
{
AParam = "A",
BParam = "B",
};

try
{
parametersWithBothOrNeitherInterdependenceBothSet.ToDictionary();
}
catch (Exceptions.General.InvalidParameterPairError)
{
Assert.Fail("Should not throw exception if both A and B are set.");
}

// Should not throw exception if neither A nor B are set.
var parametersWithBothOrNeitherInterdependenceNeitherSet = new ParameterSetWithBothOrNeitherDependentTopLevelParameters();

try
{
parametersWithBothOrNeitherInterdependenceNeitherSet.ToDictionary();
}
catch (Exceptions.General.InvalidParameterPairError)
{
Assert.Fail("Should not throw exception if neither A nor B are set.");
}
}

private sealed class ParameterSetWithCompetingParametersNonAlphabetic : Parameters.BaseParameters<EasyPostObject>
[Fact]
[Testing.Exception]
public void TestDependentNestedParameters()
{
[TopLevelRequestParameter(Necessity.Optional, "location")]
// ReSharper disable once UnusedAutoPropertyAccessor.Local
public string? BParam { get; set; }
// Either A or B must be set, but not both.

// Should throw exception if both set.
var parametersWithInterdependenceBothSet = new ParameterSetWithOneOrOtherDependentTopLevelParameters
{
AParam = "A",
BParam = "B",
};

Assert.Throws<Exceptions.General.InvalidParameterPairError>(() => parametersWithInterdependenceBothSet.ToDictionary());

// Should throw exception if neither set.
var parametersWithInterdependenceNeitherSet = new ParameterSetWithOneOrOtherDependentTopLevelParameters();

Assert.Throws<Exceptions.General.InvalidParameterPairError>(() => parametersWithInterdependenceNeitherSet.ToDictionary());

// Should not throw exception if only A is set.
var parametersWithInterdependenceOnlyASet = new ParameterSetWithOneOrOtherDependentTopLevelParameters
{
AParam = "A",
};

[TopLevelRequestParameter(Necessity.Optional, "location")]
// ReSharper disable once UnusedAutoPropertyAccessor.Local
public string? AParam { get; set; }
try
{
parametersWithInterdependenceOnlyASet.ToDictionary();
}
catch (Exceptions.General.InvalidParameterPairError)
{
Assert.Fail("Should not throw exception if only A is set.");
}

// Should not throw exception if only B is set.
var parametersWithInterdependenceOnlyBSet = new ParameterSetWithOneOrOtherDependentTopLevelParameters
{
BParam = "B",
};

try
{
parametersWithInterdependenceOnlyBSet.ToDictionary();
}
catch (Exceptions.General.InvalidParameterPairError)
{
Assert.Fail("Should not throw exception if only B is set.");
}
}

/// <summary>
Expand Down Expand Up @@ -426,6 +546,8 @@ public void TestParameterMatchOverrideFunction()
#endregion
}

#region Fixtures

#pragma warning disable CA1852 // Can be sealed
[SuppressMessage("ReSharper", "UnusedMember.Local")]
internal class ExampleDecoratorParameters : Parameters.BaseParameters<EasyPostObject>
Expand Down Expand Up @@ -460,5 +582,70 @@ internal class ExampleMatchParameters : Parameters.BaseParameters<ExampleMatchPa
public string? Prop1 { get; set; }
}

internal sealed class ParameterSetWithRequiredAndOptionalParameters : Parameters.BaseParameters<EasyPostObject>
{
[TopLevelRequestParameter(Necessity.Required, "test", "required")]
// ReSharper disable once UnusedAutoPropertyAccessor.Local
public string? RequiredParameter { get; set; }

[TopLevelRequestParameter(Necessity.Optional, "test", "optional")]
// ReSharper disable once UnusedAutoPropertyAccessor.Local
public string? OptionalParameter { get; set; }
}

internal sealed class ParameterSetWithCompetingParameters : Parameters.BaseParameters<EasyPostObject>
{
[TopLevelRequestParameter(Necessity.Optional, "location")]
// ReSharper disable once UnusedAutoPropertyAccessor.Local
public string? AParam { get; set; }

[TopLevelRequestParameter(Necessity.Optional, "location")]
// ReSharper disable once UnusedAutoPropertyAccessor.Local
public string? BParam { get; set; }
}

internal sealed class ParameterSetWithCompetingParametersNonAlphabetic : Parameters.BaseParameters<EasyPostObject>
{
[TopLevelRequestParameter(Necessity.Optional, "location")]
// ReSharper disable once UnusedAutoPropertyAccessor.Local
public string? BParam { get; set; }

[TopLevelRequestParameter(Necessity.Optional, "location")]
// ReSharper disable once UnusedAutoPropertyAccessor.Local
public string? AParam { get; set; }
}

internal sealed class ParameterSetWithOneOrOtherDependentTopLevelParameters : Parameters.BaseParameters<EasyPostObject>
{
[TopLevelRequestParameter(Necessity.Optional, "a_param")]
[TopLevelRequestParameterDependents(IndependentStatus.IfSet, DependentStatus.MustNotBeSet, "BParam")]
[TopLevelRequestParameterDependents(IndependentStatus.IfNotSet, DependentStatus.MustBeSet, "BParam")]
// ReSharper disable once UnusedAutoPropertyAccessor.Local
public string? AParam { get; set; }

[TopLevelRequestParameter(Necessity.Optional, "b_param")]
[TopLevelRequestParameterDependents(IndependentStatus.IfSet, DependentStatus.MustNotBeSet, "AParam")]
[TopLevelRequestParameterDependents(IndependentStatus.IfNotSet, DependentStatus.MustBeSet, "AParam")]
// ReSharper disable once UnusedAutoPropertyAccessor.Local
public string? BParam { get; set; }
}

internal sealed class ParameterSetWithBothOrNeitherDependentTopLevelParameters : Parameters.BaseParameters<EasyPostObject>
{
[TopLevelRequestParameter(Necessity.Optional, "a_param")]
[TopLevelRequestParameterDependents(IndependentStatus.IfSet, DependentStatus.MustBeSet, "BParam")]
[TopLevelRequestParameterDependents(IndependentStatus.IfNotSet, DependentStatus.MustNotBeSet, "BParam")]
// ReSharper disable once UnusedAutoPropertyAccessor.Local
public string? AParam { get; set; }

[TopLevelRequestParameter(Necessity.Optional, "b_param")]
[TopLevelRequestParameterDependents(IndependentStatus.IfSet, DependentStatus.MustBeSet, "AParam")]
[TopLevelRequestParameterDependents(IndependentStatus.IfNotSet, DependentStatus.MustNotBeSet, "AParam")]
// ReSharper disable once UnusedAutoPropertyAccessor.Local
public string? BParam { get; set; }
}

#pragma warning restore CA1852 // Can be sealed

#endregion
}
1 change: 1 addition & 0 deletions EasyPost/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ public static class ErrorMessages
#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member
public const string InvalidApiKeyType = "Invalid API key type.";
public const string InvalidParameter = "Invalid parameter: {0}.";
public const string InvalidParameterPair = "Invalid parameter pair: '{0}' and '{1}'.";
public const string InvalidWebhookSignature = "Webhook does not contain a valid HMAC signature.";
public const string JsonDeserializationError = "Error deserializing JSON into object of type {0}.";
public const string JsonNoDataToDeserialize = "No data to deserialize.";
Expand Down
21 changes: 21 additions & 0 deletions EasyPost/Exceptions/General/InvalidParameterPairError.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using System.Globalization;

namespace EasyPost.Exceptions.General
{
/// <summary>
/// Represents an error that occurs due to an invalid parameter pair.
/// </summary>
public class InvalidParameterPairError : ValidationError
{
/// <summary>
/// Initializes a new instance of the <see cref="InvalidParameterPairError" /> class.
/// </summary>
/// <param name="firstParameterName">The name of the first parameter in the pair.</param>
/// <param name="secondParameterName">The name of the second parameter in the pair.</param>
/// <param name="followUpMessage">Additional message to include in error message.</param>
internal InvalidParameterPairError(string firstParameterName, string secondParameterName, string? followUpMessage = "")
: base($"{string.Format(CultureInfo.InvariantCulture, Constants.ErrorMessages.InvalidParameterPair, firstParameterName, secondParameterName)}. {followUpMessage}")
{
}
}
}
22 changes: 22 additions & 0 deletions EasyPost/Parameters/BaseParameters.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,17 @@ public abstract class BaseParameters<TMatchInputType> : IBaseParameters where TM

object? value = property.GetValue(this);

// Check dependent parameters before we finish handling the current parameter
IEnumerable<TopLevelRequestParameterDependentsAttribute> dependentParameterAttributes = property.GetCustomAttributes<TopLevelRequestParameterDependentsAttribute>();
foreach (TopLevelRequestParameterDependentsAttribute dependentParameterAttribute in dependentParameterAttributes)
{
Tuple<bool, string> dependentParameterResult = dependentParameterAttribute.DependentsAreCompliant(this, value);
if (!dependentParameterResult.Item1)
{
throw new InvalidParameterPairError(firstParameterName: property.Name, secondParameterName: dependentParameterResult.Item2, followUpMessage: "Please verify the interdependence of these parameters.");
}
}

// If the value is null, check the necessity of the parameter
if (value == null)
{
Expand Down Expand Up @@ -128,6 +139,17 @@ public abstract class BaseParameters<TMatchInputType> : IBaseParameters where TM

object? value = property.GetValue(this);

// Check dependent parameters before we finish handling the current parameter
IEnumerable<NestedRequestParameterDependentsAttribute> dependentParameterAttributes = property.GetCustomAttributes<NestedRequestParameterDependentsAttribute>();
foreach (NestedRequestParameterDependentsAttribute dependentParameterAttribute in dependentParameterAttributes)
{
Tuple<bool, string> dependentParameterResult = dependentParameterAttribute.DependentsAreCompliant(this, value);
if (!dependentParameterResult.Item1)
{
throw new InvalidParameterPairError(firstParameterName: property.Name, secondParameterName: dependentParameterResult.Item2, followUpMessage: "Please verify the interdependence of these parameters.");
}
}

// If the value is null, check the necessity of the parameter
if (value == null)
{
Expand Down
4 changes: 4 additions & 0 deletions EasyPost/Parameters/Pickup/Create.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ public class Create : BaseParameters<Models.API.Pickup>, IPickupParameter
/// <see cref="Models.API.Batch"/> being set for pickup (required if <see cref="Shipment"/> not provided).
/// </summary>
[TopLevelRequestParameter(Necessity.Optional, "pickup", "batch")]
[TopLevelRequestParameterDependents(IndependentStatus.IfSet, DependentStatus.MustNotBeSet, "Shipment")]
[TopLevelRequestParameterDependents(IndependentStatus.IfNotSet, DependentStatus.MustBeSet, "Shipment")]
public IBatchParameter? Batch { get; set; }

/// <summary>
Expand Down Expand Up @@ -65,6 +67,8 @@ public class Create : BaseParameters<Models.API.Pickup>, IPickupParameter
/// <see cref="Models.API.Shipment"/> being set for pickup (required if <see cref="Batch"/> not provided).
/// </summary>
[TopLevelRequestParameter(Necessity.Optional, "pickup", "shipment")]
[TopLevelRequestParameterDependents(IndependentStatus.IfSet, DependentStatus.MustNotBeSet, "Batch")]
[TopLevelRequestParameterDependents(IndependentStatus.IfNotSet, DependentStatus.MustBeSet, "Batch")]
public IShipmentParameter? Shipment { get; set; }

#endregion
Expand Down

0 comments on commit 902336c

Please sign in to comment.