TODO: Consider refacor this namespace by defining custom schema with [[mr/def]] instead.

For example the PositiveInt can be defined as (mr/def ::positive-int pos-int?)

(ns metabase.util.malli.schema
  (:require
   [clojure.string :as str]
   [malli.core :as mc]
   [metabase.legacy-mbql.normalize :as mbql.normalize]
   [metabase.legacy-mbql.schema :as mbql.s]
   [metabase.lib.schema.common :as lib.schema.common]
   [metabase.lib.schema.temporal-bucketing :as lib.schema.temporal-bucketing]
   [metabase.models.dispatch :as models.dispatch]
   [metabase.util :as u]
   [metabase.util.date-2 :as u.date]
   [metabase.util.i18n :as i18n :refer [deferred-tru]]
   [metabase.util.json :as json]
   [metabase.util.malli :as mu]
   [metabase.util.password :as u.password]))
(set! *warn-on-reflection* true)

-------------------------------------------------- Utils --------------------------------------------------

Helper for creating a schema to check whether something is an instance of model.

(ms/defn my-fn [user :- (ms/InstanceOf User)] ...)

TODO -- consider renaming this to InstanceOfModel to differentiate it from [[InstanceOfClass]]

(def ^{:arglists '([model])} InstanceOf
  (memoize
   (fn [model]
     (mu/with-api-error-message
      [:fn
       {:error/message (format "value must be an instance of %s" (name model))}
       #(models.dispatch/instance-of? model %)]
      (deferred-tru "value must be an instance of {0}" (name model))))))

Helper for creating schemas to check whether something is an instance of a given class.

(def ^{:arglists '([^Class klass])} InstanceOfClass
  (memoize
   (fn [^Class klass]
     [:fn
      {:error/message (format "Instance of a %s" (.getCanonicalName klass))}
      (partial instance? klass)])))

Given a schema of a sequence of maps, returns a schema that does an additional unique check on key k.

(def ^{:arglists '([maps-schema k])} maps-with-unique-key
  (memoize
   (fn [maps-schema k]
     (mu/with-api-error-message
      [:and
       [:fn (fn [maps]
              (= (count maps)
                 (-> (map #(get % k) maps)
                     distinct
                     count)))]
       maps-schema]
      (deferred-tru "value must be seq of maps in which {0}s are unique" (name k))))))

Returns an enum schema that accept both keywords and strings. (enum-keywords-and-strings :foo :bar) ;; => [:enum :foo :bar "foo" "bar"]

(defn enum-keywords-and-strings
  [& keywords]
  (assert (every? keyword? keywords))
  (vec (concat [:enum] keywords (map u/qualified-name keywords))))

-------------------------------------------------- Schemas --------------------------------------------------

Schema for a string that cannot be blank.

(def NonBlankString
  (mu/with-api-error-message ::lib.schema.common/non-blank-string (deferred-tru "value must be a non-blank string.")))

Schema representing an integer than must also be greater than or equal to zero.

(def IntGreaterThanOrEqualToZero
  (mu/with-api-error-message
   [:int {:min 0}]
    ;; FIXME: greater than _or equal to_ zero.
   (deferred-tru "value must be an integer greater than zero.")))

Schema representing an integer.

(def Int
  (mu/with-api-error-message
   int?
   (deferred-tru "value must be an integer.")))

Schema representing an integer than must also be greater than zero.

(def PositiveInt
  (mu/with-api-error-message
   pos-int?
   (deferred-tru "value must be an integer greater than zero.")))

Schema representing an integer than must be less than zero.

(def NegativeInt
  (mu/with-api-error-message
   neg?
   (deferred-tru "value must be a negative integer")))

Schema representing a numeric value greater than zero. This allows floating point numbers and integers.

(def PositiveNum
  (mu/with-api-error-message
   [:and number? pos?]
   (deferred-tru "value must be a number greater than zero.")))

Schema for something that can be either a Keyword or a String.

(def KeywordOrString
  (mu/with-api-error-message
   [:or :string :keyword]
   (deferred-tru "value must be a keyword or string.")))

Schema for a valid Field base or effective (data) type (does it derive from :type/*)?

(def FieldType
  (mu/with-api-error-message
   [:fn #(isa? % :type/*)]
   (deferred-tru "value must be a valid field type.")))

Schema for a valid Field semantic type deriving from :Semantic/*.

(def FieldSemanticType
  (mu/with-api-error-message
   [:fn #(isa? % :Semantic/*)]
   (deferred-tru "value must be a valid field semantic type.")))

Schema for a valid Field relation type deriving from :Relation/*

(def FieldRelationType
  (mu/with-api-error-message
   [:fn #(isa? % :Relation/*)]
   (deferred-tru "value must be a valid field relation type.")))

Schema for a valid Field semantic or Relation type. This is currently needed because the semantic_column is used to store either the semantic type or relation type info. When this is changed in the future we can get rid of this schema. See #15486.

(def FieldSemanticOrRelationType
  (mu/with-api-error-message
   [:fn (fn [k] (or (isa? k :Semantic/*) (isa? k :Relation/*)))]
   (deferred-tru "value must be a valid field semantic or relation type.")))

Schema for a valid Field coercion strategy (does it derive from :Coercion/*)?

(def CoercionStrategy
  (mu/with-api-error-message
   [:fn #(isa? % :Coercion/*)]
   (deferred-tru "value must be a valid coercion strategy.")))

Like FieldType (e.g. a valid derivative of :type/*) but allows either a keyword or a string. This is useful especially for validating API input or objects coming out of the DB as it is unlikely those values will be encoded as keywords at that point.

(def FieldTypeKeywordOrString
  (mu/with-api-error-message
   [:fn #(isa? (keyword %) :type/*)]
   (deferred-tru "value must be a valid field data type (keyword or string).")))

Like FieldSemanticType but accepts either a keyword or string.

(def FieldSemanticTypeKeywordOrString
  (mu/with-api-error-message
   [:fn #(isa? (keyword %) :Semantic/*)]
   (deferred-tru "value must be a valid field semantic type (keyword or string).")))

Like FieldRelationType but accepts either a keyword or string.

(def FieldRelationTypeKeywordOrString
  (mu/with-api-error-message
   [:fn #(isa? (keyword %) :Relation/*)]
   (deferred-tru "value must be a valid field relation type (keyword or string).")))

Like FieldSemanticOrRelationType but accepts either a keyword or string.

(def FieldSemanticOrRelationTypeKeywordOrString
  (mu/with-api-error-message
   [:fn (fn [k]
          (let [k (keyword k)]
            (or (isa? k :Semantic/*)
                (isa? k :Relation/*))))]
   (deferred-tru "value must be a valid field semantic or relation type (keyword or string).")))

Schema for a valid legacy :field or :expression reference for API usage. TODO -- why are these passed into the REST API at all? MBQL clauses are not things we should ask for as API parameters.

(def LegacyFieldOrExpressionReference
  (mu/with-api-error-message
   [:fn (fn [k]
          ((comp (mc/validator mbql.s/Field)
                 mbql.normalize/normalize-tokens) k))]
   (deferred-tru "value must an array with :field id-or-name and an options map")))

Like CoercionStrategy but accepts either a keyword or string.

(def CoercionStrategyKeywordOrString
  (mu/with-api-error-message
   [:fn #(isa? (keyword %) :Coercion/*)]
   (deferred-tru "value must be a valid coercion strategy (keyword or string).")))

Validates entity type derivatives of :entity/*. Allows strings or keywords

(def EntityTypeKeywordOrString
  (mu/with-api-error-message
   [:fn #(isa? (keyword %) :entity/*)]
   (deferred-tru "value must be a valid entity type (keyword or string).")))

Schema for a valid map.

(def Map
  (mu/with-api-error-message
   :map
   (deferred-tru "Value must be a map.")))

Schema for a valid email string.

(def Email
  (mu/with-api-error-message
   [:and
    :string
    [:fn u/email?]]
   (deferred-tru "value must be a valid email address.")))

Schema for a valid URL string.

(def Url
  (mu/with-api-error-message
   [:fn u/url?]
   (deferred-tru "value must be a valid URL.")))

Schema for a valid password of sufficient complexity which is not found on a common password list.

(def ValidPassword
  (mu/with-api-error-message
   [:and
    :string
    [:fn (every-pred string? #'u.password/is-valid?)]]
   (deferred-tru "password is too common.")))

Schema for a string that can be parsed as an integer. Something that adheres to this schema is guaranteed to to work with Integer/parseInt.

(def IntString
  (mu/with-api-error-message
   [:and
    :string
    [:fn #(u/ignore-exceptions (Integer/parseInt %))]]
   (deferred-tru "value must be a valid integer.")))

Schema for a string that can be parsed as an integer, and is greater than zero. Something that adheres to this schema is guaranteed to to work with Integer/parseInt.

(def IntStringGreaterThanZero
  (mu/with-api-error-message
   [:and
    :string
    [:fn #(u/ignore-exceptions (< 0 (Integer/parseInt %)))]]
   (deferred-tru "value must be a valid integer greater than zero.")))

Schema for a string that can be parsed as an integer, and is greater than or equal to zero. Something that adheres to this schema is guaranteed to to work with Integer/parseInt.

(def IntStringGreaterThanOrEqualToZero
  (mu/with-api-error-message
   [:and
    :string
    [:fn #(u/ignore-exceptions (<= 0 (Integer/parseInt %)))]]
   (deferred-tru "value must be a valid integer greater than or equal to zero.")))

Schema for a string that can be parsed by date2/parse.

(def TemporalString
  (mu/with-api-error-message
   [:and
    :string
    [:fn #(u/ignore-exceptions (boolean (u.date/parse %)))]]
   (deferred-tru "value must be a valid date string")))

Schema for a string that is valid serialized JSON.

(def JSONString
  (mu/with-api-error-message
   [:and
    :string
    [:fn #(try
            (json/decode %)
            true
            (catch Throwable _
              false))]]
   (deferred-tru "value must be a valid JSON string.")))
(def ^:private keyword-or-non-blank-str-malli
  (mc/schema
   [:or {:json-schema {:type "string" :minLength 1}} :keyword NonBlankString]))

Schema for a valid representation of a boolean (one of "true" or true or "false" or false.). Used by [[metabase.api.common/defendpoint]] to coerce the value for this schema to a boolean. Garanteed to evaluate to true or false when passed through a json decoder.

(def BooleanValue
  (-> [:enum {:decode/json (fn [b] (contains? #{"true" true} b))
              :json-schema {:type "boolean"}}
       "true" "false" true false]
      (mu/with-api-error-message
       (deferred-tru "value must be a valid boolean string (''true'' or ''false'')."))))

Same as above, but allows distinguishing between nil (the user did not specify a value) and false (the user specified false).

(def MaybeBooleanValue
  (-> [:enum {:decode/json (fn [b] (some->> b (contains? #{"true" true})))
              :json-schema {:type "boolean" :optional true}}
       "true" "false" true false nil]
      (mu/with-api-error-message
       (deferred-tru "value must be a valid boolean string (''true'' or ''false'')."))))

Schema for valid source_options within a Parameter

(def ValuesSourceConfig
  ;; TODO: This should be tighter
  (mc/schema
   [:map
    [:values {:optional true} [:* :any]]
    [:card_id {:optional true} PositiveInt]
    [:value_field {:optional true} LegacyFieldOrExpressionReference]
    [:label_field {:optional true} LegacyFieldOrExpressionReference]]))

Has two components: 1. (can be anything) 2. (must be a string)

(def RemappedFieldValue
  [:tuple :any :string])

Has one component:

(def NonRemappedFieldValue
  [:tuple :any])

Schema for a valid list of values for a field, in contexts where the field can have a remapped field.

(def FieldValuesList
  [:sequential [:or RemappedFieldValue NonRemappedFieldValue]])

Schema for a value result of fetching the values for a field, in contexts where the field can have a remapped field.

(def FieldValuesResult
  [:map
   [:has_more_values :boolean]
   [:values FieldValuesList]])

Schema for a valid Parameter. We're not using [metabase.legacy-mbql.schema/Parameter] here because this Parameter is meant to be used for Parameters we store on dashboard/card, and it has some difference with Parameter in MBQL.

#_(def ParameterSource
    (mc/schema
     [:multi {:dispatch :values_source_type}
      ["card"        [:map
                      [:values_source_type :string]
                      [:values_source_config
                       [:map {:closed true}
                        [:card_id {:optional true} IntGreaterThanZero]
                        [:value_field {:optional true} Field]
                        [:label_field {:optional true} Field]]]]]
      ["static-list" [:map
                      [:values_source_type :string]
                      [:values_source_config
                       [:map {:closed true}
                        [:values {:optional true} [:* :any]]]]]]]))
(def Parameter
  ;; TODO we could use :multi to dispatch values_source_type to the correct values_source_config
  (mu/with-api-error-message
   [:map [:id NonBlankString]
    [:type keyword-or-non-blank-str-malli]
     ;; TODO how to merge this with ParameterSource above?
    [:values_source_type {:optional true} [:enum "static-list" "card" nil]]
    [:values_source_config {:optional true} ValuesSourceConfig]
    [:slug {:optional true} :string]
    [:name {:optional true} :string]
    [:default {:optional true} :any]
    [:sectionId {:optional true} NonBlankString]
    [:temporal_units {:optional true}
     [:sequential ::lib.schema.temporal-bucketing/unit]]]
   (deferred-tru "parameter must be a map with :id and :type keys")))

Schema for a valid Parameter Mapping

(def ParameterMapping
  (mu/with-api-error-message
   [:map [:parameter_id NonBlankString]
    [:target :any]
    [:card_id {:optional true} PositiveInt]]
   (deferred-tru "parameter_mapping must be a map with :parameter_id and :target keys")))

Schema for a valid map of embedding params.

(def EmbeddingParams
  (mu/with-api-error-message
   [:maybe [:map-of
            :keyword
            [:enum "disabled" "enabled" "locked"]]]
   (deferred-tru "value must be a valid embedding params map.")))

Schema for a valid ISO Locale code e.g. en or en-US. Case-insensitive and allows dashes or underscores.

(def ValidLocale
  (mu/with-api-error-message
   [:and
    NonBlankString
    [:fn i18n/available-locale?]]
   (deferred-tru "String must be a valid two-letter ISO language or language-country code e.g. 'en' or 'en_US'.")))

Schema for a 21-character NanoID string, like "FReCLx5hSWTBU7kjCWfuu".

(def NanoIdString
  (mu/with-api-error-message
   [:re #"^[A-Za-z0-9_\-]{21}$"]
   (deferred-tru "String must be a valid 21-character NanoID string.")))

Schema for a UUID string

(def UUIDString
  (mu/with-api-error-message
   [:re u/uuid-regex]
   (deferred-tru "value must be a valid UUID.")))

Helper for creating schemas to check whether something is an instance of a collection.

(defn CollectionOf
  [item-schema]
  [:fn
   {:error/message (format "Collection of %s" item-schema)}
   #(and (coll? %) (every? (partial mc/validate item-schema) %))])

Helper for creating a schema that coerces single-value to a vector. Useful for coercing query parameters.

(defn QueryVectorOf
  [schema]
  [:vector {:decode/string (fn [x] (cond (vector? x) x x [x]))} schema])

Helper for creating a schema to check if a map doesn't contain kebab case keys.

(defn MapWithNoKebabKeys
  []
  [:fn
   {:error/message "Map should not contain any kebab-case keys"}
   (fn [m]
     ;; reduce-kv is more efficient that iterating over (keys m). But we have to extract the underlying map from
     ;; Toucan2 Instance because it doesn't implement IKVReduce (yet).
     (let [m (if (instance? toucan2.instance.Instance m)
               (.m ^toucan2.instance.Instance m)
               m)]
       (reduce-kv (fn [_ k _]
                    (if (str/includes? k "-")
                      (reduced false)
                      true))
                  true m)))])

Schema for a file coming in HTTP request from multipart/form-data

(def File
  [:map {:closed true}
   [:content-type string?]
   [:filename string?]
   [:size int?]
   [:tempfile (InstanceOfClass java.io.File)]])