Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added default message and param to OptsFn #116

Closed
wants to merge 4 commits into from

Conversation

nevaldas
Copy link
Contributor

I'd like to offer this change.
The idea of this is to have default message as well as param name in the OptsFn.
In our case, we want to override the thrown exceptions by our ones, so we could easily handle them. However, we don't want to lose the message (which contains all provided values to the ensure).
For instance, if we have:
Ensure.String.HasLengthBetween(name, 0, 10, nameof(Name));
The default exception and message would "ArgumentException" and "The string is too long. Must be between '0' and '10'.
But we want to catch only validation errors, so we could show the administrator what the issue is. That means that "ArgumentException" is a bit too risky for us.
So the desired outcome would be exception of our type, let's say, "DomainValidationException" and message to remain the same. With ".WithException" we would need to duplicate the given arguments as well as the message, for example:
Ensure.String.HasLengthBetween(name, 0, 10, nameof(Name), o => o.WithException(new DomainValidationException("The string is too long. Must be between '0' and '10'.", nameof(Name))));
Which means that if range changes, it might be that developer wouldn't change the message.
Also, the code looks abit noisy.
The solution with given changes would be:
Ensure.String.HasLengthBetween(name, 0, 10, nameof(Name), EnsureHelper.WithValidationException);
Where EnsureHelper would have such property:
public static OptsFn WithValidationException => (options, defaultMessage, paramName) => options.WithException(new DomainValidationException(defaultMessage, paramName));
Way cleaner, isn't it?
However, current solution means breaking changes, because it requires to change the OptsFn delegate. I could offer another solution: pass the default message and param name to EnsureOptions' constructor. Then such change would not be a breaking one. I could make such change if needed.

@danielwertheim
Copy link
Owner

@nevaldas thanks for the contribution. Sorry for the late response.

@ndrwrbgs What's your input on this?

@ndrwrbgs
Copy link
Contributor

Oh, hi, I was asked a question :-D
let me review :)

@ndrwrbgs
Copy link
Contributor

ndrwrbgs commented Feb 22, 2020

I understand the use case but I don't think it's common enough (at this point) to warrant the breaking change.

Separately, it brings up some questions like if we do a change like this, should OptsFn have also be passed the input value, so it can use that in the exception message ("Your string was 100 characters long").

To accommodate your scenario without breaking existing users I'd suggest making a middleware layer that you call in your methods instead of calling Ensure directly, and have that layer catch the exceptions from Ensure to rethrow exceptions with your specified type. Catching the ArgumentException should not be a concern as long as you're not catching it from both Ensure calls and your business logic that might have them coming from elsewhere, as you noticed.

An example would be:

void Main()
{
  Abc("123");
}

// Define other methods and classes here
void Abc(string i)
{
  DomainValidation.ArgumentValidationBlock(delegate {
    Ensure.That(i).IsNotNull();
  });
}

static class DomainValidation
{
  public static void ArgumentValidationBlock(Action action)
  {
    try
    {
      action();
    }
    catch(ArgumentException ae)
    {
      // Ideally you'd also pass ae as the inner exception here anyway
      // , to get the stack trace of the original exception from 
      // Ensure.That. You "never" want to just drop an exception
      // TODO: Use your exception type
      throw new NullReferenceException(ae.Message, ae /* passing ae as inner exception */);
    }
  }
}

@danielwertheim
Copy link
Owner

@ndrwrbgs If taking that middleware route the argument should be passed through, right? Just to get rid of the risk of not being able to cache the closure?

E.g.

static class DomainValidation
{
  public static void ArgumentValidationBlock<T>(T i, Action<T> action)
  {
    try
    {
      action(i);
    }
    catch(ArgumentException ae)
    {
      // Ideally you'd also pass ae as the inner exception here anyway
      // , to get the stack trace of the original exception from 
      // Ensure.That. You "never" want to just drop an exception
      // TODO: Use your exception type
      throw new NullReferenceException(ae.Message, ae /* passing ae as inner exception */);
    }
  }
}

@danielwertheim
Copy link
Owner

If we populated the EnsureOptions struct with the default message and the value we would be set right? And that struct would only be created if a lambda was injected.

@ndrwrbgs
Copy link
Contributor

ndrwrbgs commented Feb 23, 2020 via email

@nevaldas
Copy link
Contributor Author

What if EnsureOptions looked like this?

public struct EnsureOptions
    {
        /// <summary>
        /// If <see cref="CustomExceptionFactory"/> is defined, this exception will be thrown instead of the
        /// standard exceptions for the particular ensure method.
        /// Assign using <see cref="WithException"/>.
        /// </summary>
        public Exception CustomException => CustomExceptionFactory(null);

        /// <summary>
        /// If defined, this exception will be thrown instead of the
        /// standard exceptions for the particular ensure method.
        /// Assign using <see cref="WithException"/>.
        /// </summary>
        public Func<string, Exception> CustomExceptionFactory { get; private set; }

        /// <summary>
        /// If defined, and no <see cref="CustomExceptionFactory"/> has been defined,
        /// this message will be used instead of the standard message for the
        /// particular ensure method.
        /// Assign using <see cref="WithMessage"/>.
        /// </summary>
        public string CustomMessage { get; private set; }

        public EnsureOptions WithException(Exception ex)
        {
            CustomExceptionFactory = x => ex;

            return this;
        }

        public EnsureOptions WithException(Func<string, Exception> factory)
        {
            CustomExceptionFactory = factory;

            return this;
        }

        public EnsureOptions WithMessage(string message)
        {
            CustomMessage = message;

            return this;
        }
    }

Then ExceptionFactory could be like this:

if (opts.CustomExceptionFactory != null)
                    return opts.CustomExceptionFactory(defaultMessage);

This has no breaking changes for the users. One could think about removal of CustomException, or at least add [Obsolete], so nobody would use its getter.
If this is good enough, I can commit it.

@ndrwrbgs
Copy link
Contributor

ndrwrbgs commented Feb 23, 2020 via email

@nevaldas
Copy link
Contributor Author

nevaldas commented Mar 4, 2020

@danielwertheim any news regarding this PR?

/// </summary>
public Exception CustomException { get; private set; }
public Func<string, Exception> CustomExceptionFactory { get; private set; }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right now this takes only the original message, right?
I feel like taking the paramname and value would be more important than preserving whatever text the library supplied out of box. Thoughts?

Copy link
Contributor Author

@nevaldas nevaldas Nov 9, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it does indeed take only the original message.
Not sure if paramname and value gives any value. During the call to Ensure, I already know both of these. In that case, I could already do this, without this pull request, right?
Ensure.String.HasLengthBetween(name, 0, 10, nameof(Name), o => o.WithException(new DomainValidationException($"The given string \"{name}\" is too long. Must be between '0' and '10'.", nameof(Name))));
However, what I wanted to achieve is not to do anything, just to change the type of the exception, preserving the message.

EDIT:
I had a wild idea - maybe then we should pass the default exception? That way we could keep the message (or even customize it), And one could pass it as inner exception if needed. The factory would look like this:

internal Exception ArgumentException([NotNull] string defaultMessage, string paramName, OptsFn optsFn = null)
        {
            Exception Generate(string message) => new ArgumentException(message, paramName);

            if (optsFn != null)
            {
                var opts = optsFn(new EnsureOptions());

                if (opts.CustomExceptionFactory != null)
                    return opts.CustomExceptionFactory(Generate(defaultMessage));

                if (opts.CustomMessage != null)
                    return Generate(opts.CustomMessage);
            }

            return Generate(defaultMessage);
        }

/// standard exceptions for the particular ensure method.
/// Assign using <see cref="WithException(Exception)"/>.
/// </summary>
public Exception CustomException => CustomExceptionFactory(string.Empty);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] minor perf hit on method call for folks using this API. As I don't use it, I am not going to be the evangelist that nay-says, but something to keep in mind as the OP is a user of this customization.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/I advise we not let performance regressions prevent innovation, I feel an obligation to note it for stakeholders, but we can fix issues when(/IF) they are an issue for someone/

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, but could you explain how this has performance hit? Properties are methods under the hood, AFAIK, so this does not change. The factory also returns straight-away the given exception. What am I missing?

@danielwertheim
Copy link
Owner

@nevaldas thanks for this PR and the contrib. Sorry for the looooooong response time. I kept the CustomException part intact and also added the message and param name for the factory to work with as well as opened up for anyone to replace the ExceptionFactory as the see fit. This change would not have happened unless you had opened this PR. Thanks. Again sorry for the late response.

Feel free to indicate if I missed any functionality.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants