class-validator-jsonschema icon indicating copy to clipboard operation
class-validator-jsonschema copied to clipboard

IsOptional support for null

Open MrBlenny opened this issue 6 years ago • 11 comments

I'm not sure whether this is part of as conditional decorator limitations in the readme. In any case...

The @IsOptional decorator should be adding anyOf: [{type: someType}, {type: 'null'}] as well as removing the property from the required array. It doesn't seem to be doing the former.

I note that internally, class validator uses conditionalValidation for the IsOptional decorator. Am I correct that the limitation is that this doesn't work when there are multiple decorators for one field? For example:

@IsOptional()
@IsNumber()
thing: number

@IsOptional()
@IsSting()
otherThing: string

The functionality of IsOptional depends on the other decorators.

MrBlenny avatar Dec 03 '18 04:12 MrBlenny

Hi! That's a good point, IsOptional should indeed add a null schema as well as remove the property from the required array. And yes, as class-validator uses ValidationTypes.CONDITIONAL_VALIDATION for both @IsOptional and @ValidateIf, adding a converter for CONDITIONAL_VALIDATION to add the null schema is tricky since inside the converter function we can't reliably differentiate between @IsOptional and @ValidateIf. Your second assumption is also correct, I'm afraid. At least that's my impression after a brief look; if you've got a solution that'd be great.

I'll try to look into this more when there's time. For the time being you might want to override the default ValidationTypes.CONDITIONAL_VALIDATION decorator.

epiphone avatar Dec 04 '18 13:12 epiphone

My temporary solution for now is to traverse the object and convert all nulls to undefined. I will look into a better solution if required. Thanks 😃

MrBlenny avatar Dec 05 '18 23:12 MrBlenny

Personally, I don't think @IsOptional should automatically allow null on a property that perhaps has other validations. Null is a very specific value that can have meaning. I always understood @IsOptional to mean if the property exists, then ensure it follows the other validations, and if it doesn't exist, that's ok.

This is especially relevant when I'm implementing a PATCH API call where all properties in the body are optional, but each must pass some validation if included, and null is a valid value for a property.

loban avatar Oct 09 '19 19:10 loban

@loban that's totally reasonable, but the way @IsOptional is implemented is that it

Checks if given value is empty (=== null, === undefined) and if so, ignores all the validators on the property.

and this library should naturally match class-validator's definition as closely as possible.

epiphone avatar Oct 10 '19 05:10 epiphone

Taking into account the current @IsOptional behavior, what could be a workaround for what @loban stated?

xjuanc avatar Jan 10 '20 12:01 xjuanc

@xjuanc you could use the additionalConverters option to override the default @IsOptional conversion (which atm does nothing) as mentioned above.

epiphone avatar Jan 11 '20 01:01 epiphone

Hello,

Any news on this? How can i validate or null or enum?

This doesn't work.

  @IsOptional()
  @IsEnum(["c", "a", "r"], { each: true })
  @Transform((value: string) => value.split(","))
  status: string[]

incompletude avatar Jun 02 '20 15:06 incompletude

mark

xxxsf avatar Mar 03 '21 06:03 xxxsf

Hello,

Any news on this? How can i validate or null or enum?

This doesn't work.

  @IsOptional()
  @IsEnum(["c", "a", "r"], { each: true })
  @Transform((value: string) => value.split(","))
  status: string[]

Hello, I use the following code for enum validation:

@IsIn(Object.values(ConversationStatusEnum))

Jurajzovinec avatar Feb 14 '22 21:02 Jurajzovinec

You can replace @isOptional() to @ValidateIf(o => '{fieldName}' in o) for those optional fields that should not be updated with null

Validator accepts {id: null}

@IsOptional()
@IsNotEmpty()
readonly id: string;

Validator blocks {id: null}

@ValidateIf(o => 'id' in o)
@IsNotEmpty()
readonly id: string;

sunghyunl22 avatar May 24 '22 13:05 sunghyunl22

I hope someone finds this helpful, I was able to get optionals working properly like so:


const refPointerPrefix = '#/components/schemas/';


function getPropType(target: object, property: string) {
  return Reflect.getMetadata('design:type', target, property);
}

export {JSONSchema} from 'class-validator-jsonschema';

type Options = IOptions & {
  definitions: Record<string, SchemaObject>;
};

function nestedClassToJsonSchema(clz: Constructor<any>, options: Partial<Options>): SchemaObject {
  return targetConstructorToSchema(clz, options) as any;
}


const additionalConverters: ISchemaConverters = {
  [ValidationTypes.NESTED_VALIDATION]: (meta: ValidationMetadata, options: IOptions) => {
    if (typeof meta.target === 'function') {

      const typeMeta = options.classTransformerMetadataStorage
        ? options.classTransformerMetadataStorage.findTypeMetadata(meta.target, meta.propertyName)
        : null;

      const childType = typeMeta
        ? typeMeta.typeFunction()
        : getPropType(meta.target.prototype, meta.propertyName);

      const schema = targetToSchema(childType, options);

      const name = meta.target.name;

      if (!!schema && !!schema.$ref && schema.$ref === '#/components/schemas/Object') {
        schema.$ref = `${refPointerPrefix}${name}`;
      }

      const isOptional = Reflect.getMetadata('isOptional', meta.target.prototype, meta.propertyName);

      if (isOptional) {
        const anyof: (SchemaObject | ReferenceObject)[] = [
          {
            type: 'null'
          } as any
        ];
        if (schema && schema.$ref) {
          anyof.push({$ref: schema.$ref});
        }
        if (anyof.length === 1) {
          return {
            type: 'null'
          };
        } else {
          return {
            anyof
          };
        }
      } else {
        return schema;
      }
    }
  },
} as any;
export const defaultClassValidatorJsonSchemaOptions: Partial<Options> = {
  refPointerPrefix,
  additionalConverters,
  classTransformerMetadataStorage: defaultMetadataStorage
};

export function classToJsonSchema(clz: Constructor<any>): SchemaObject {
  const options = {...defaultClassValidatorJsonSchemaOptions, definitions: {}};
  const schema = targetConstructorToSchema(clz, options) as any;
  schema.definitions = options.definitions;
  return schema;
}

function targetToSchema(type: any, options: IOptions): any | void {
  if (typeof type === 'function') {
    if (type.prototype === String.prototype || type.prototype === Symbol.prototype) {
      return {type: 'string'};
    } else if (type.prototype === Number.prototype) {
      return {type: 'number'};
    } else if (type.prototype === Boolean.prototype) {
      return {type: 'boolean'};
    }

    return {$ref: options.refPointerPrefix + type.name};
  }
}

however, as you can see, we need to get isOptional from Reflect.getMetadata('isOptional', meta.target.prototype, meta.propertyName); and for this, I found no better way than to make a custom Optional decorator which mimics IsOptional and reuses it but also sets additional metadata:

/**
 * Optional - mimics @IsOptional decorator but adds custom metadata so that it is easily available in
 * Reflect to check if the property is optional
 * @constructor
 */
export const Optional = () => {
  return function (object: NonNullable<unknown>, propertyName: string) {
    // Apply the standard @IsOptional() decorator
    IsOptional()(object, propertyName);

    console.log(object instanceof Cdr);
    // Add custom metadata for additional use cases
    Reflect.defineMetadata('isOptional', true, object, propertyName);
  };
};

this mimics IsOptional except it gives us the ability to be able to call Reflect.getMetadata('isOptional', meta.target.prototype, meta.propertyName); in ValidationTypes.NESTED_VALIDATION and have a solid way of knowing that this field is nullable, which we can then properly handle. Keep in mind, since we are using the NESTED_VALIDATION, we also need to make sure that the field is annotated with @NestedValidation(). So my class looks something like:

export class User {
   @Optional()
   @NestedValidation()
   @Type(() => Token) // this is needed as well for typeMeta.typeFunction() to work in the code above
   token?: Token
}

Please let me know if you find a better way of doing this and hopefully this comes in helpful as it took me a few hours to get this working properly.

Overall though, we do now have the appropriate schema:

"token": {
  "anyOf": [
    {
      "$ref": "#/components/schemas/Token"
    },
    {
      "type": null
    }
  ]
},

elliot-sabitov avatar May 09 '24 20:05 elliot-sabitov