papsign / Ktor-OpenAPI-Generator

Ktor OpenAPI/Swagger 3 Generator
Apache License 2.0
241 stars 42 forks source link

How to create custom validators with the latest version? #56

Open bargergo opened 4 years ago

bargergo commented 4 years ago

I've created custom validators with the 0.1-beta.2 version and I wanted to update to the latest version (0.2-beta.5). The validation system changed a lot and I can't figure it out how to write a custom validator. I've only found example on custom validators for the old version.

bargergo commented 4 years ago

Ok, now I've found some example code from the built-in validators/transformers, but I still don't know how could I write a custom validator that validates if the length of a string equals to a number.

Annotation

package com.papsign.ktor.openapigen.validation.string.lowercase

import com.papsign.ktor.openapigen.validation.ValidatorAnnotation

@Target(AnnotationTarget.PROPERTY, AnnotationTarget.TYPE)
@ValidatorAnnotation(LowerCaseValidator::class)
annotation class LowerCase

Validator

package com.papsign.ktor.openapigen.validation.string.lowercase

import com.papsign.ktor.openapigen.getKType
import com.papsign.ktor.openapigen.validation.Validator
import com.papsign.ktor.openapigen.validation.util.SingleTypeValidator

object LowerCaseValidator : SingleTypeValidator<LowerCase>(getKType<String>(), { LowerCaseValidator }), Validator {
    override fun <T> validate(subject: T?): T? {
        @Suppress("UNCHECKED_CAST")
        return (subject as String?)?.toLowerCase() as T?
    }
}
Wicpar commented 4 years ago

Yes it changed quite a bit to get rid of the registration process, now you can just provide the annotation and the annotation itself provides the handler. Take a look at https://github.com/papsign/Ktor-OpenAPI-Generator/blob/master/src/main/kotlin/com/papsign/ktor/openapigen/annotations/type/number/NumberConstraintProcessor.kt

Just ignore the abstract part, implement the SchemaProcessor and Validator builder. If the validation fails an exception should be thrown.

Then you create an annotation like this:


@Target(AnnotationTarget.TYPE, AnnotationTarget.PROPERTY)
@SchemaProcessorAnnotation(MinProcessor::class)
@ValidatorAnnotation(MinProcessor::class)
annotation class Min(val value: Long)

I will have to write a wiki page for it indeed.

bargergo commented 4 years ago

Thanks for your help! I've managed to implement the custom validator I wanted. I'm sharing my code, maybe it will be helpful for others.

I've wrote a LengthConstraintProcessor class based on the NumberConstraintProcessor class

abstract class LengthConstraintProcessor<A: Annotation>(): SchemaProcessor<A>, ValidatorBuilder<A> {

    val types = listOf(getKType<String>().withNullability(true), getKType<String>().withNullability(false))

    abstract fun process(model: SchemaModel<*>, annotation: A): SchemaModel<*>

    abstract fun getConstraint(annotation: A): LengthConstraint

    private class LengthConstraintValidator(private val constraint: LengthConstraint): Validator {
        override fun <T> validate(subject: T?): T? {
            if (subject is String?) {
                val value = subject?.length ?: 0
                if (constraint.min != null) {
                    if (value < constraint.min) throw LengthConstraintViolation(value, constraint)
                }
                if (constraint.max != null) {
                    if (value > constraint.max) throw LengthConstraintViolation(value, constraint)
                }
            } else {
                throw NotAStringViolation(subject)
            }
            return subject
        }
    }

    override fun build(type: KType, annotation: A): Validator {
        return if (types.contains(type)) {
            LengthConstraintValidator(getConstraint(annotation))
        } else {
            error("${annotation::class} can only be used on types: $types")
        }
    }

    override fun process(model: SchemaModel<*>, type: KType, annotation: A): SchemaModel<*> {
        return if (types.contains(type)) {
            process(model, annotation)
        } else {
            model
        }
    }
}

data class LengthConstraint(val min: Int? = null, val max: Int? = null, val errorMessage: String? = null)

open class ConstraintViolation(message: String, cause: Throwable? = null): Exception(message, cause)

class LengthConstraintViolation(val actual: Number?, val constraint: LengthConstraint): ConstraintViolation(constraint.errorMessage ?: "Constraint violation: the length of the string should be ${
{
    val min = "${constraint.min}"
    val max = "${constraint.max}"
    when {
        constraint.min != null && constraint.max != null -> "between $min and $max"
        constraint.min != null -> "at least $min"
        constraint.max != null -> "at most $max"
        else -> "anything"
    }
}()
}, but it is $actual")

class NotAStringViolation(val value: Any?): ConstraintViolation("Constraint violation: $value is not a string")

Annotation

@Target(AnnotationTarget.TYPE, AnnotationTarget.PROPERTY)
@SchemaProcessorAnnotation(ExactLengthProcessor::class)
@ValidatorAnnotation(ExactLengthProcessor::class)
annotation class ExactLength(val value: Int, val message: String = "")

Processor class

object ExactLengthProcessor : LengthConstraintProcessor<ExactLength>() {
    override fun process(model: SchemaModel<*>, annotation: ExactLength): SchemaModel<*> {
        // There is no string schema and couldn't create one, because SchemaModel is a sealed class
        return model
    }

    override fun getConstraint(annotation: ExactLength): LengthConstraint {
        val errorMessage = if (annotation.message.isNotEmpty()) annotation.message else null
        return LengthConstraint(min = annotation.value, max = annotation.value, errorMessage = errorMessage)
    }
}
Wicpar commented 4 years ago

Nice :) Feel free to open a pull request if you wish to contribute the annotations and add the missing properties in the model. Maybe i should change the model to a delegated hashmap, constantly changing the model is getting tedious...

bargergo commented 4 years ago

Ok, I've created a pull request. I couldn't test it, yet, because I have problems with adding the modified gradle project as dependency to a project correctly.

Edit: I have managed to import the modified project somehow. The validation works, but the new constraints are missing from the schema. I think the Schema builders can't handle the new SchemaModelString class correctly.