All Downloads are FREE. Search and download functionalities are using the official Maven repository.

caliban.validation.SchemaValidator.scala Maven / Gradle / Ivy

The newest version!
package caliban.validation

import caliban.CalibanError.ValidationError
import caliban.InputValue
import caliban.introspection.adt.__TypeKind._
import caliban.introspection.adt.{ __Field, __InputValue, __Type, __TypeKind }
import caliban.parsing.Parser
import caliban.parsing.adt.Directive
import caliban.schema.{ RootSchema, RootSchemaBuilder, Types }
import caliban.validation.Utils.isObjectType
import caliban.validation.ValidationOps._

private[caliban] object SchemaValidator {

  /**
   * Verifies that the given schema is valid. Fails with a [[caliban.CalibanError.ValidationError]] otherwise.
   */
  def validateSchema[R](schema: RootSchemaBuilder[R]): Either[ValidationError, RootSchema[R]] = {
    val types = schema.types.sorted
    for {
      _      <- validateAllDiscard(types)(validateType)
      _      <- validateClashingTypes(types)
      _      <- validateDirectives(types)
      _      <- validateRootMutation(schema)
      _      <- validateRootSubscription(schema)
      schema <- validateRootQuery(schema)
    } yield schema
  }

  private[caliban] def validateType(t: __Type): Either[ValidationError, Unit] =
    t.name.fold[Either[ValidationError, Unit]](unit)(name => checkName(name, s"Type '$name'")) *>
      (t.kind match {
        case __TypeKind.ENUM         => validateEnum(t)
        case __TypeKind.UNION        => validateUnion(t)
        case __TypeKind.INTERFACE    => validateInterface(t)
        case __TypeKind.INPUT_OBJECT => validateInputObject(t)
        case __TypeKind.OBJECT       => validateObject(t)
        case _                       => unit
      })

  private def validateClashingTypes(types: List[__Type]): Either[ValidationError, Unit] = {
    val check = types.groupBy(_.name).collectFirst { case (Some(name), v) if v.size > 1 => (name, v) }
    check match {
      case None                 => unit
      case Some((name, values)) =>
        failValidation(
          s"Type '$name' is defined multiple times (${values
              .sortBy(v => v.origin.getOrElse(""))
              .map(v => s"${v.kind}${v.origin.fold("")(a => s" in $a")}")
              .mkString(", ")}).",
          "Each type must be defined only once."
        )
    }
  }

  private def validateDirectives(types: List[__Type]): Either[ValidationError, Unit] = {

    def validateArguments(
      args: Map[String, InputValue],
      errorContext: => String
    ): Either[ValidationError, Unit] = {
      val argumentErrorContextBuilder = (name: String) => s"Argument '$name' of $errorContext"
      validateAllDiscard(args.keys.toList)(argName => checkName(argName, argumentErrorContextBuilder(argName)))
    }

    def validateDirective(directive: Directive, errorContext: => String) = {
      lazy val directiveErrorContext = s"Directive '${directive.name}' of $errorContext"

      checkName(directive.name, directiveErrorContext) *>
        validateArguments(directive.arguments, directiveErrorContext)
    }

    def validateDirectives(
      directives: Option[List[Directive]],
      errorContext: => String
    ): Either[ValidationError, Unit] =
      validateAllDiscard(directives.getOrElse(List.empty))(validateDirective(_, errorContext))

    def validateInputValueDirectives(
      inputValues: List[__InputValue],
      errorContext: => String
    ): Either[ValidationError, Unit] = {
      val inputValueErrorContextBuilder = (name: String) => s"InputValue '$name' of $errorContext"
      validateAllDiscard(inputValues)(iv => validateDirectives(iv.directives, inputValueErrorContextBuilder(iv.name)))
    }

    def validateFieldDirectives(
      field: __Field,
      errorContext: => String
    ): Either[ValidationError, Unit] = {
      lazy val fieldErrorContext = s"Field '${field.name}' of $errorContext"
      validateDirectives(field.directives, fieldErrorContext) *>
        validateInputValueDirectives(field.allArgs, fieldErrorContext)
    }

    validateAllDiscard(types) { t =>
      lazy val typeErrorContext = s"Type '${t.name.getOrElse("")}'"
      for {
        _ <- validateDirectives(t.directives, typeErrorContext)
        _ <- validateInputValueDirectives(t.allInputFields, typeErrorContext)
        _ <- validateAllDiscard(t.allFields)(validateFieldDirectives(_, typeErrorContext))
      } yield ()
    }
  }

  private def validateEnum(t: __Type): Either[ValidationError, Unit] =
    t.allEnumValues match {
      case _ :: _ => unit
      case Nil    =>
        failValidation(
          s"Enum ${t.name.getOrElse("")} doesn't contain any values",
          "An Enum type must define one or more unique enum values."
        )
    }

  private def validateUnion(t: __Type): Either[ValidationError, Unit] =
    t.possibleTypes match {
      case None | Some(Nil)                           =>
        failValidation(
          s"Union ${t.name.getOrElse("")} doesn't contain any type.",
          "A Union type must include one or more unique member types."
        )
      case Some(types) if !types.forall(isObjectType) =>
        failValidation(
          s"Union ${t.name.getOrElse("")} contains the following non Object types: " +
            types.filterNot(isObjectType).map(_.name.getOrElse("")).filterNot(_.isEmpty).mkString("", ", ", "."),
          s"The member types of a Union type must all be Object base types."
        )
      case _                                          => unit
    }

  private def validateInputObject(t: __Type): Either[ValidationError, Unit] = {
    lazy val inputObjectContext = s"""${if (t._isOneOfInput) "OneOf " else ""}InputObject '${t.name.getOrElse("")}'"""

    def noDuplicateInputValueName(
      inputValues: List[__InputValue],
      errorContext: => String
    ): Either[ValidationError, Unit] = {
      val messageBuilder = (i: __InputValue) => s"$errorContext has repeated fields: ${i.name}"
      def explanatory    =
        "The input field must have a unique name within that Input Object type; no two input fields may share the same name"
      noDuplicateName[__InputValue](inputValues, _.name, messageBuilder, explanatory)
    }

    def noDuplicatedOneOfOrigin(inputValues: List[__InputValue]): Either[ValidationError, Unit] = {
      val resolveOrigin  = (i: __InputValue) =>
        i._parentType.flatMap(_.origin).getOrElse("")
      val messageBuilder = (i: __InputValue) =>
        s"$inputObjectContext is extended by a case class with multiple arguments: ${resolveOrigin(i)}"
      val explanatory    = "All case classes used as arguments to OneOf Input Objects must have exactly one field"
      noDuplicateName[__InputValue](inputValues, resolveOrigin, messageBuilder, explanatory)
    }

    def validateFields(fields: List[__InputValue]): Either[ValidationError, Unit] =
      validateAllDiscard(fields)(validateInputValue(_, inputObjectContext)) *>
        noDuplicateInputValueName(fields, inputObjectContext)

    def validateOneOfFields(fields: List[__InputValue]): Either[ValidationError, Unit] =
      noDuplicatedOneOfOrigin(fields) *>
        validateAllDiscard(fields) { f =>
          failWhen(f.defaultValue.isDefined)(
            s"$inputObjectContext argument has a default value",
            "Fields of OneOf input objects cannot have default values"
          ) *>
            failWhen(!f._type.isNullable)(
              s"$inputObjectContext argument is not nullable",
              "All of OneOf input fields must be declared as nullable in the schema according to the spec"
            )
        }

    t.allInputFields match {
      case Nil                       =>
        failValidation(
          s"$inputObjectContext does not have fields",
          "An Input Object type must define one or more input fields"
        )
      case fields if t._isOneOfInput => validateOneOfFields(fields) *> validateFields(fields)
      case fields                    => validateFields(fields)
    }
  }

  private def validateInputValue(
    inputValue: __InputValue,
    errorContext: => String
  ): Either[ValidationError, Unit] = {
    lazy val fieldContext = s"InputValue '${inputValue.name}' of $errorContext"
    for {
      _ <- ValueValidator.validateDefaultValue(inputValue, fieldContext)
      _ <- checkName(inputValue.name, fieldContext)
      _ <- onlyInputType(inputValue._type, fieldContext)
    } yield ()
  }

  private def validateInterface(t: __Type): Either[ValidationError, Unit] = {
    lazy val interfaceContext = s"Interface '${t.name.getOrElse("")}'"

    t.allFields match {
      case Nil    =>
        failValidation(
          s"$interfaceContext does not have fields",
          "An Interface type must define one or more fields"
        )
      case fields => validateFields(fields, interfaceContext)
    }
  }

  private def validateObject(obj: __Type): Either[ValidationError, Unit] = {
    lazy val objectContext = s"Object '${obj.name.getOrElse("")}'"

    def validateInterfaceFields(obj: __Type) = {
      def fieldNames(t: __Type) = t.allFields.map(_.name)

      val supertype = obj.interfaces().toList.flatten

      def checkForMissingFields(): Either[ValidationError, Unit] = {
        val objectFieldNames    = fieldNames(obj).toSet
        val interfaceFieldNames = supertype.flatMap(fieldNames).toSet
        val isMissingFields     = objectFieldNames.union(interfaceFieldNames) != objectFieldNames

        failWhen(interfaceFieldNames.nonEmpty && isMissingFields)(
          {
            val missingFields = interfaceFieldNames.diff(objectFieldNames).toList.sorted
            s"$objectContext is missing field(s): ${missingFields.mkString(", ")}"
          },
          "An Object type must include a field of the same name for every field defined in an interface"
        )
      }

      def checkForInvalidSubtypeFields(): Either[ValidationError, Unit] = {
        val objectFields    = obj.allFields
        val supertypeFields = supertype.flatMap(_.allFields)

        def isNonNullableSubtype(supertypeFieldType: __Type, objectFieldType: __Type) = {
          import __TypeKind._
          objectFieldType.kind match {
            case NON_NULL => objectFieldType.ofType.exists(Types.same(supertypeFieldType, _))
            case _        => false
          }
        }

        def isValidSubtype(supertypeFieldType: __Type, objectFieldType: __Type) = {
          val supertypePossibleTypes = supertypeFieldType.possibleTypes.toList.flatten

          Types.same(supertypeFieldType, objectFieldType) ||
          supertypePossibleTypes.exists(Types.same(_, objectFieldType)) ||
          isNonNullableSubtype(supertypeFieldType, objectFieldType)
        }

        validateAllDiscard(objectFields) { objField =>
          lazy val fieldContext = s"Field '${objField.name}'"

          supertypeFields.find(_.name == objField.name) match {
            case None             => unit
            case Some(superField) =>
              val superArgs = superField.allArgs.map(arg => (arg.name, arg)).toMap
              val extraArgs = objField.allArgs.filter { arg =>
                superArgs.get(arg.name).fold(true)(superArg => !Types.same(arg._type, superArg._type))
              }

              def fieldTypeIsValid = isValidSubtype(superField._type, objField._type)

              def listItemTypeIsValid =
                isListField(superField) && isListField(objField) && (for {
                  superListItemType <- superField._type.ofType
                  objListItemType   <- objField._type.ofType
                } yield isValidSubtype(superListItemType, objListItemType)).getOrElse(false)

              def extraArgsAreValid = !extraArgs.exists(_._type.kind == __TypeKind.NON_NULL)

              (fieldTypeIsValid, isListField(superField)) match {
                case (_, true) if !listItemTypeIsValid =>
                  failValidation(
                    s"$fieldContext in $objectContext is an invalid list item subtype",
                    "An object list item field type must be equal to or a possible" +
                      " type of the interface list item field type."
                  )
                case (false, false)                    =>
                  failValidation(
                    s"$fieldContext in $objectContext is an invalid subtype",
                    "An object field type must be equal to or a possible type of the interface field type."
                  )
                case _ if !extraArgsAreValid           =>
                  val argNames = extraArgs.filter(_._type.kind == __TypeKind.NON_NULL).map(_.name).mkString(", ")
                  failValidation(
                    s"$fieldContext with extra non-nullable arg(s) '$argNames' in $objectContext is invalid",
                    "Any additional field arguments must not be of a non-nullable type."
                  )
                case _                                 => unit
              }
          }
        }
      }

      for {
        _ <- checkForMissingFields()
        _ <- checkForInvalidSubtypeFields()
      } yield ()
    }

    obj.allFields match {
      case Nil    =>
        failValidation(
          s"$objectContext does not have fields",
          "An Object type must define one or more fields"
        )
      case fields => validateFields(fields, objectContext) *> validateInterfaceFields(obj)
    }
  }

  private def isListField(field: __Field) =
    field._type.kind == __TypeKind.LIST

  private def onlyInputType(`type`: __Type, errorContext: => String): Either[ValidationError, Unit] = {
    // https://spec.graphql.org/June2018/#IsInputType()
    def isInputType(t: __Type): Either[__Type, Unit] = {
      import __TypeKind._
      t.kind match {
        case LIST | NON_NULL              => t.ofType.fold[Either[__Type, Unit]](Left(t))(isInputType)
        case SCALAR | ENUM | INPUT_OBJECT => Right(())
        case _                            => Left(t)
      }
    }

    isInputType(`type`) match {
      case Left(errorType) =>
        failValidation(
          s"${errorType.name.getOrElse("")} of $errorContext is of kind ${errorType.kind}, must be an InputType",
          """The input field must accept a type where IsInputType(type) returns true, https://spec.graphql.org/June2018/#IsInputType()"""
        )
      case Right(_)        => unit
    }
  }

  private def validateFields(fields: List[__Field], context: => String): Either[ValidationError, Unit] =
    noDuplicateFieldName(fields, context) *>
      validateAllDiscard(fields) { field =>
        lazy val fieldContext = s"Field '${field.name}' of $context"
        for {
          _ <- checkName(field.name, fieldContext)
          _ <- onlyOutputType(field._type, fieldContext)
          _ <- validateAllDiscard(field.allArgs)(validateInputValue(_, fieldContext))
        } yield ()
      }

  private def noDuplicateFieldName(fields: List[__Field], errorContext: => String) = {
    val messageBuilder = (f: __Field) => s"$errorContext has repeated fields: ${f.name}"
    def explanatory    =
      "The field must have a unique name within that Interface type; no two fields may share the same name"
    noDuplicateName[__Field](fields, _.name, messageBuilder, explanatory)
  }

  private def onlyOutputType(`type`: __Type, errorContext: => String): Either[ValidationError, Unit] = {
    // https://spec.graphql.org/June2018/#IsOutputType()
    def isOutputType(t: __Type): Either[__Type, Unit] = {
      import __TypeKind._
      t.kind match {
        case LIST | NON_NULL                            => t.ofType.fold[Either[__Type, Unit]](Left(t))(isOutputType)
        case SCALAR | OBJECT | INTERFACE | UNION | ENUM => Right(())
        case _                                          => Left(t)
      }
    }

    isOutputType(`type`) match {
      case Left(errorType) =>
        failValidation(
          s"${errorType.name.getOrElse("")} of $errorContext is of kind ${errorType.kind}, must be an OutputType",
          """The input field must accept a type where IsOutputType(type) returns true, https://spec.graphql.org/June2018/#IsInputType()"""
        )
      case Right(_)        => unit
    }
  }

  private def noDuplicateName[T](
    listOfNamed: List[T],
    nameExtractor: T => String,
    messageBuilder: T => String,
    explanatoryText: => String
  ): Either[ValidationError, Unit] =
    listOfNamed
      .groupBy(nameExtractor(_))
      .collectFirst { case (_, f :: _ :: _) => f }
      .fold[Either[ValidationError, Unit]](unit)(duplicate =>
        failValidation(messageBuilder(duplicate), explanatoryText)
      )

  private def checkName(name: String, fieldContext: => String): Either[ValidationError, Unit] =
    Parser
      .parseName(name)
      .left
      .map(e =>
        ValidationError(
          s"$fieldContext is not a valid name.",
          s"Name does not conform to the GraphQL spec for names: ${e.msg}"
        )
      ) *> doesNotStartWithUnderscore(name, fieldContext)

  private def doesNotStartWithUnderscore(
    name: String,
    errorContext: => String
  ): Either[ValidationError, Unit] =
    failWhen(name.startsWith("__"))(
      s"$errorContext can't start with '__'",
      """Names can not begin with the characters "__" (two underscores)"""
    )

  private def validateRootQuery[R](
    schema: RootSchemaBuilder[R]
  ): Either[ValidationError, RootSchema[R]] =
    schema.query match {
      case None        =>
        failValidation(
          "The query root operation is missing.",
          "The query root operation type must be provided and must be an Object type."
        )
      case Some(query) =>
        if (query.opType.kind == __TypeKind.OBJECT)
          Right(RootSchema(query, schema.mutation, schema.subscription))
        else
          failValidation(
            "The query root operation is not an object type.",
            "The query root operation type must be provided and must be an Object type."
          )
    }

  private def validateRootMutation[R](schema: RootSchemaBuilder[R]): Either[ValidationError, Unit] =
    schema.mutation match {
      case Some(mutation) if mutation.opType.kind != __TypeKind.OBJECT =>
        failValidation(
          "The mutation root operation is not an object type.",
          "The mutation root operation type is optional; if it is not provided, the service does not support mutations. If it is provided, it must be an Object type."
        )
      case _                                                           => unit
    }

  private def validateRootSubscription[R](schema: RootSchemaBuilder[R]): Either[ValidationError, Unit] =
    schema.subscription match {
      case Some(subscription) if subscription.opType.kind != __TypeKind.OBJECT =>
        failValidation(
          "The mutation root subscription is not an object type.",
          "The mutation root subscription type is optional; if it is not provided, the service does not support subscriptions. If it is provided, it must be an Object type."
        )
      case _                                                                   => unit
    }

  private def failValidation(msg: String, explanatoryText: String): Either[ValidationError, Nothing] =
    Left(ValidationError(msg, explanatoryText))

}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy