hapijs / joi

The most powerful data validation library for JS
Other
20.74k stars 1.51k forks source link

Strip Any Value #3040

Open ZachHaber opened 1 month ago

ZachHaber commented 1 month ago

Runtime

browser

Runtime version

125

Module version

17.13.0

Used with

standalone

Any other relevant information

No response

What problem are you trying to solve?

I'd like to have a convenient way to remove a key from an object regardless of validation status for that object. This becomes necessary when using when, alter, fork, etc. Any time you want to alter an existing schema to strip a value from a schema - especially when the alteration is because you no longer care what was in it, just that it's removed.

This has been asked many times: https://github.com/hapijs/joi/issues/1385, https://github.com/hapijs/joi/issues/2372, https://github.com/hapijs/joi/issues/2533, https://github.com/hapijs/joi/issues/2423. It's been a source of confusion with no solution that I could find while searching for a few hours today.

The best approach I've come up with so far without new functionality within Joi is Joi.optional().strip().empty(Joi.any()). This will override the key by making everything empty so that it is valid to be stripped. However, it fails if an empty was already applied to the key. If you need to use empty, then you have to apply it in the non-stripped case of the conditional so that it doesn't prevent the stripping from working.


For context, what I am doing is trying to strip two keys when they should be hidden due to business logic (two separate fields with specific values). The simple case of the values of the keys being either fully valid or completely empty is simple since you can use Joi.any().strip(). It becomes problematic when you have invalid data in the keys you wish to strip.

I've gone through several iterations of solutions as I attempted to work through the problem, and this is the most complete and least verbose one so far. Unfortunately, it is not without its issues. As mentioned, you can't use empty outside of the when statements, which makes it so you have to nest the whens inside of each other in order to be able to apply an empty when all the conditions to hide it fail. These solutions become more unwieldy/nested if you end up needing to use more fields ORed against each other to remove a key.

Joi.object({
  change: Joi.object({
    isImmediateRisk: Joi.boolean(),
    nested: Joi.object({
      critical: Joi.boolean(),
    }),
  }).required(),
  overallImplementation: Joi.object({ value: Joi.valid(5).required() })
    .when('change.isImmediateRisk', {
      is: true,
      then: Joi.optional().strip().empty(Joi.any()),
      otherwise: Joi.when('change.nested.critical', {
        is: true,
        then: Joi.optional().strip().empty(Joi.any()),
        otherwise: Joi.any().empty([null]),
      }),
    }),
});

When this is run with

{
  change:{
    isImmediateRisk: false,
     nested: {
       critical: true
     }
    },
  overallImplementation: {}
}

it results in

{
  "change": {
    "isImmediateRisk": false,
    "nested": {
      "critical": true
    }
  }
}

If you just try the whens without nesting them, then it will fail if you apply a .empty either to the start or the end of the chain (after the .whens). The following will fail with "overallImplementation.value" is required, but it will work if you remove the .empty unless the overallImplementation value is null, in which case it will fail if both conditions evaluate to false, instead of being undefined like I want it to be. (I'm using presence: 'required' and optional to allow saving as a draft but keeping validations of the actual data itself valid.)

Joi.object({
  change: Joi.object({
    isImmediateRisk: Joi.boolean(),
    nested: Joi.object({
      critical: Joi.boolean(),
    }),
  }).required(),
  overallImplementation: Joi.object({ value: Joi.valid(5).required() })
    .empty([null])
    .when('change.isImmediateRisk', {
      is: true,
      then: Joi.optional().strip().empty(Joi.any()),
    })
    .when('change.nested.critical', {
      is: true,
      then: Joi.optional().strip().empty(Joi.any()),
    }),
})

Similarly, using otherwise in both when conditions makes the stripping only work when both conditions evaluate to true:

    .when('change.isImmediateRisk', {
      is: true,
      then: Joi.optional().strip().empty(Joi.any()),
      otherwise: Joi.any().empty([null])
    })
    .when('change.nested.critical', {
      is: true,
      then: Joi.optional().strip().empty(Joi.any()),
      otherwise: Joi.any().empty([null])
    })

Do you have a new or modified API suggestion to solve the problem?

  1. A new Symbol called something akin to removeKey that when merged with an object removes the key from the object - allowing stripUnknown to work on that key due to it no-longer existing.
  2. An AnySchema.forceStrip() function or an option passable to Any.strip(true, {force: true}) that could modify a schema to be removed no matter what.
  3. AnySchema.replace(schema) which would either act as just the schema, but would replace whatever it merges into with the schema.
    // This would remove the key from the resulting schema as a whole
    Joi.object({toIgnore: Joi.string()}).concat(Joi.object({toIgnore: Joi.replace(undefined)}))
    // This would make `toIgnore` be `Joi.any().strip()`
    Joi.object({toIgnore: Joi.string()}).concat(Joi.object({toIgnore: Joi.replace(Joi.any().strip())}))
    // This would make `toIgnore` be `Joi.number()`
    Joi.object({toIgnore: Joi.string()}).concat(Joi.object({toIgnore: Joi.replace(Joi.number())}))
    // Similarly, this would do the exact same thing - The last merged replacement would win out.
    Joi.object({toIgnore: Joi.replace(Joi.string())}).concat(Joi.object({toIgnore: Joi.replace(Joi.number())}))
Marsup commented 3 weeks ago

Maybe I'm misunderstanding the intention behind your schema, but wouldn't this work?

Joi.object({
  change: Joi.object({
    isImmediateRisk: Joi.boolean(),
    nested: Joi.object({
      critical: Joi.boolean(),
    }),
  }).required(),
  overallImplementation: Joi.optional().when("change.isImmediateRisk", {
    is: true,
    then: Joi.strip(),
    break: true,
  }).when("change.nested.critical", {
    is: true,
    then: Joi.strip(),
    otherwise: Joi.object({ value: Joi.valid(5).required() }).empty([null]),
  }),
})