Repository: mlflow/mlflow

Issue:
[FR] Validate request JSON with JSON schema or similar
Thank you for submitting a feature request. **Before proceeding, please review MLflow's [Issue Policy for feature requests](https://www.github.com/mlflow/mlflow/blob/master/ISSUE_POLICY.md#feature-requests) and the [MLflow Contributing Guide](https://github.com/mlflow/mlflow/blob/master/CONTRIBUTING.rst)**.

**Please fill in this feature request template to ensure a timely and thorough response.**

## Willingness to contribute
The MLflow Community encourages new feature contributions. Would you or another member of your organization be willing to contribute an implementation of this feature (either as an MLflow Plugin or an enhancement to the MLflow code base)?

- [ ] Yes. I can contribute this feature independently.
- [x] Yes. I would be willing to contribute this feature with guidance from the MLflow community.
- [ ] No. I cannot contribute this feature at this time.

## Proposal Summary

API calls should return `HTTP 400` when the parameters (e.g.) don't match expected data types instead of failing with a `500`. Creating a JSON schema -- using [jsonschema](https://github.com/Julian/jsonschema), for example -- for the MLFlow REST API to check requests against would fix these issues. This would result in far friendlier UX, easier debugging, more predictable responses, and a generally more RESTful API.

## Motivation

I keep getting `500` errors for things like supplying a parameter to an API call that's the wrong data type. See [this issue](https://github.com/mlflow/mlflow/issues/5199) for an example. This has also happened with calls to logging parameters (both individually and in batches) and all kinds of other functions.

Right now, this means that an end user of a running MLFlow service get an error message like this back when something goes wrong:

```
Response [https://<<host>>/api/2.0/mlflow/runs/log-batch]
  Date: 2021-12-29 20:15
  Status: 500
  Content-Type: text/html; charset=utf-8
  Size: 290 B
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">
<title>500 Internal Server Error</title>
<h1>Internal Server Error</h1>
<p>The server encountered an internal error and was unable to complete your request. Either the server is overloaded or there is an error in the application.</p>
```

This error was caused by providing a `timestamp` value to `log-batch` that was a character string as opposed to a numeric timestamp.

Obviously, this error is unhelpful. There's no indication of what went wrong or how to fix the issue. More importantly, the `500` is a hint that the client actually _did not do anything wrong_, and that there was a legitimate issue on the server side. For bad parameters (e.g.), this is obviously not the case, and the client should be seeing an error message with information about the incorrect parameter type and what type was expected, not a cryptic `500` with `Unable to complete your request`.

The value prop here should be relatively obvious, so I won't write too much beyond just saying that validating requests against a JSON schema would let users of the MLFlow REST API (in other words, every MLFlow user) more easily and reliably use MLFlow, develop wrappers for the MLFlow API, debug their code when things go wrong, etc. etc. etc.

### What component(s), interfaces, languages, and integrations does this feature affect?
Components 
- [ ] `area/artifacts`: Artifact stores and artifact logging
- [ ] `area/build`: Build and test infrastructure for MLflow
- [ ] `area/docs`: MLflow documentation pages
- [ ] `area/examples`: Example code
- [ ] `area/model-registry`: Model Registry service, APIs, and the fluent client calls for Model Registry
- [ ] `area/models`: MLmodel format, model serialization/deserialization, flavors
- [ ] `area/projects`: MLproject format, project running backends
- [ ] `area/scoring`: MLflow Model server, model deployment tools, Spark UDFs
- [x] `area/server-infra`: MLflow Tracking server backend
- [x] `area/tracking`: Tracking Service, tracking client APIs, autologging

Interfaces
- [ ] `area/uiux`: Front-end, user experience, plotting, JavaScript, JavaScript dev server
- [ ] `area/docker`: Docker use across MLflow's components, such as MLflow Projects and MLflow Models
- [ ] `area/sqlalchemy`: Use of SQLAlchemy in the Tracking Service or Model Registry
- [ ] `area/windows`: Windows support

Languages 
- [ ] `language/r`: R APIs and clients
- [ ] `language/java`: Java APIs and clients
- [ ] `language/new`: Proposals for new client languages

Integrations
- [ ] `integrations/azure`: Azure and Azure ML integrations
- [ ] `integrations/sagemaker`: SageMaker integrations
- [ ] `integrations/databricks`: Databricks integrations

## Details

I haven't written any JSON schema for Python, but in R I know it's easy to just set up a function to validate requests and then use that function to validate the JSON body of any requests that come in before doing any actual work. If a request fails the JSON validation checks, you can easily return an `HTTP 400 -- JSON validation failed with << some error >>`.

Let me know if I can help with this improvement! I think it'd be a major step forward for everyone using MLFlow and for the project in general.



Discussion:
@mrkaye97 Apologies for the delay. We'd be very excited about this contribution. Please let me know if you have any questions about getting started.
Hey @dbczumar, sure, I'll give it a shot! I haven't worked on any API stuff in Python but have done similar things in other languages. I'll try a small batch first and see how it goes, and will probably request feedback on styling, best practices, etc. 
Alright @dbczumar, circling back just to clarify I'm looking in the right place before spending a bunch of time. It appears to me that in `mlflow/server/handlers.py` I will need to do something like this? (e.g.):

```python
@catch_mlflow_exception
@_disable_if_artifacts_only
def _get_experiment():
    request_message = _get_request_message(GetExperiment())

    ## At this point, we want to check the request_message against a schema
    ## If the checks pass, continue, else, 400. This will be factored into a function that takes JSON and a schema
    ##  and 400s on failure, and otherwise returns either a placeholder like None or true to indicate validation success

    response_message = GetExperiment.Response()
    experiment = _get_tracking_store().get_experiment(request_message.experiment_id).to_proto()
    response_message.experiment.MergeFrom(experiment)
    response = Response(mimetype="application/json")
    response.set_data(message_to_json(response_message))
    return response
```

Let me know if I'm misunderstanding what's happening inside of this method. If this seems correct. I will give it a shot
@mrkaye97 That sounds great!
Checking in again @dbczumar. In theory, I think this change should be fairly straightforward. See [this comment](https://github.com/mlflow/mlflow/pull/5350/files#diff-5c10a4e2ca47745f06fa9e7201087acfc102849756cb8d85e774a5ac468cb037R340-R341) in #5350 for an issue I'm having though. It seems like the `_get_foobar()` endpoints call `_get_request_message()`, which in turn calls `parse_dict`, which calls `google.protobuf.json_format.ParseDict`, which appears to take the thing that we need for JSON validation (the dictionary) and parses it somehow and maybe returns the same dict that was passed in. Is that correct? If it is, I should be able to just use the result of `ParseDict` for validation.

Any input would be appreciated! Thanks
Hi @mrkaye97 , yes, you can use the result of `ParseDict` for validation. To ensure that validation is applied across the various handlers, I'd recommend adding it to `_get_request_message()`. You can creating a mapping from each message type to its associated validation function; when `_get_request_message()` is called, it should resolve the output of `ParseDict` to the appropriate validation function and invoke it. If `ParseDict` fails, we should return a 400 (not sure if we're doing this already). Thank you for taking this on!

Resolving patch:
diff --git a/mlflow/server/handlers.py b/mlflow/server/handlers.py
--- a/mlflow/server/handlers.py
+++ b/mlflow/server/handlers.py
@@ -11,6 +11,7 @@
 
 from flask import Response, request, current_app, send_file
 from google.protobuf import descriptor
+from google.protobuf.json_format import ParseError
 
 from mlflow.entities import Metric, Param, RunTag, ViewType, ExperimentTag, FileInfo
 from mlflow.entities.model_registry import RegisteredModelTag, ModelVersionTag
@@ -262,11 +263,110 @@ def initialize_backend_stores(backend_store_uri=None, default_artifact_root=None
         pass
 
 
+def _assert_string(x):
+    assert isinstance(x, str)
+
+
+def _assert_intlike(x):
+    try:
+        x = int(x)
+    except ValueError:
+        pass
+
+    assert isinstance(x, int)
+
+
+def _assert_bool(x):
+    assert isinstance(x, bool)
+
+
+def _assert_floatlike(x):
+    try:
+        x = float(x)
+    except ValueError:
+        pass
+
+    assert isinstance(x, float)
+
+
+def _assert_array(x):
+    assert isinstance(x, list)
+
+
+def _assert_required(x):
+    assert x is not None
+    # When parsing JSON payloads via proto, absent string fields
+    # are expressed as empty strings
+    assert x != ""
+
+
+def _assert_less_than_or_equal(x, max_value):
+    assert x <= max_value
+
+
+def _assert_item_type_string(x):
+    assert all(map(lambda item: isinstance(item, str), x))
+
+
+_TYPE_VALIDATORS = {
+    _assert_intlike,
+    _assert_string,
+    _assert_bool,
+    _assert_floatlike,
+    _assert_array,
+    _assert_item_type_string,
+}
+
+
+def _validate_param_against_schema(schema, param, value, proto_parsing_succeeded=False):
+    """
+    Attempts to validate a single parameter against a specified schema.
+    Examples of the elements of the schema are type assertions and checks for required parameters.
+    Returns None on validation success. Otherwise, raises an MLFlowException if an assertion fails.
+    This method is intended to be called for side effects.
+
+            Parameters:
+    :param schema: A list of functions to validate the parameter against.
+    :param param: The string name of the parameter being validated.
+    :param value: The corresponding value of the `param` being validated.
+    :param proto_parsing_succeeded: A boolean value indicating whether proto parsing succeeded.
+                                    If the proto was successfully parsed, we assume all of the types
+                                    of the parameters in the request body were correctly specified,
+                                    and thus we skip validating types. If proto parsing failed,
+                                    then we validate types in addition to the rest of the schema.
+                                    For details, see https://github.com/mlflow/mlflow/pull/
+                                    5458#issuecomment-1080880870.
+    """
+
+    for f in schema:
+        if f in _TYPE_VALIDATORS and proto_parsing_succeeded:
+            continue
+
+        try:
+            f(value)
+        except AssertionError:
+            if f == _assert_required:
+                message = f"Missing value for required parameter '{param}'."
+            else:
+                message = (
+                    f"Invalid value {value} for parameter '{param}' supplied."
+                    f" Hint: Value was of type '{type(value).__name__}'."
+                )
+            raise MlflowException(
+                message=(
+                    message + " See the API docs for more information about request parameters."
+                ),
+                error_code=INVALID_PARAMETER_VALUE,
+            )
+
+    return None
+
+
 def _get_request_json(flask_request=request):
     return flask_request.get_json(force=True, silent=True)
 
 
-def _get_request_message(request_message, flask_request=request):
+def _get_request_message(request_message, flask_request=request, schema=None):
     from querystring_parser import parser
 
     if flask_request.method == "GET" and len(flask_request.query_string) > 0:
@@ -305,7 +405,23 @@ def _get_request_message(request_message, flask_request=request):
     # If request doesn't have json body then assume it's empty.
     if request_json is None:
         request_json = {}
-    parse_dict(request_json, request_message)
+
+    proto_parsing_succeeded = True
+    try:
+        parse_dict(request_json, request_message)
+    except ParseError:
+        proto_parsing_succeeded = False
+
+    schema = schema or {}
+    for schema_key, schema_validation_fns in schema.items():
+        if schema_key in request_json or _assert_required in schema_validation_fns:
+            _validate_param_against_schema(
+                schema=schema_validation_fns,
+                param=schema_key,
+                value=request_json.get(schema_key),
+                proto_parsing_succeeded=proto_parsing_succeeded,
+            )
+
     return request_message
 
 
@@ -421,7 +537,16 @@ def _not_implemented():
 @catch_mlflow_exception
 @_disable_if_artifacts_only
 def _create_experiment():
-    request_message = _get_request_message(CreateExperiment())
+
+    request_message = _get_request_message(
+        CreateExperiment(),
+        schema={
+            "name": [_assert_required, _assert_string],
+            "artifact_location": [_assert_string],
+            "tags": [_assert_array],
+        },
+    )
+
     tags = [ExperimentTag(tag.key, tag.value) for tag in request_message.tags]
     experiment_id = _get_tracking_store().create_experiment(
         request_message.name, request_message.artifact_location, tags
@@ -436,7 +561,9 @@ def _create_experiment():
 @catch_mlflow_exception
 @_disable_if_artifacts_only
 def _get_experiment():
-    request_message = _get_request_message(GetExperiment())
+    request_message = _get_request_message(
+        GetExperiment(), schema={"experiment_id": [_assert_required, _assert_string]}
+    )
     response_message = GetExperiment.Response()
     experiment = _get_tracking_store().get_experiment(request_message.experiment_id).to_proto()
     response_message.experiment.MergeFrom(experiment)
@@ -448,7 +575,9 @@ def _get_experiment():
 @catch_mlflow_exception
 @_disable_if_artifacts_only
 def _get_experiment_by_name():
-    request_message = _get_request_message(GetExperimentByName())
+    request_message = _get_request_message(
+        GetExperimentByName(), schema={"experiment_name": [_assert_required, _assert_string]}
+    )
     response_message = GetExperimentByName.Response()
     store_exp = _get_tracking_store().get_experiment_by_name(request_message.experiment_name)
     if store_exp is None:
@@ -466,7 +595,9 @@ def _get_experiment_by_name():
 @catch_mlflow_exception
 @_disable_if_artifacts_only
 def _delete_experiment():
-    request_message = _get_request_message(DeleteExperiment())
+    request_message = _get_request_message(
+        DeleteExperiment(), schema={"experiment_id": [_assert_required, _assert_string]}
+    )
     _get_tracking_store().delete_experiment(request_message.experiment_id)
     response_message = DeleteExperiment.Response()
     response = Response(mimetype="application/json")
@@ -477,7 +608,9 @@ def _delete_experiment():
 @catch_mlflow_exception
 @_disable_if_artifacts_only
 def _restore_experiment():
-    request_message = _get_request_message(RestoreExperiment())
+    request_message = _get_request_message(
+        RestoreExperiment(), schema={"experiment_id": [_assert_required, _assert_string]}
+    )
     _get_tracking_store().restore_experiment(request_message.experiment_id)
     response_message = RestoreExperiment.Response()
     response = Response(mimetype="application/json")
@@ -488,7 +621,13 @@ def _restore_experiment():
 @catch_mlflow_exception
 @_disable_if_artifacts_only
 def _update_experiment():
-    request_message = _get_request_message(UpdateExperiment())
+    request_message = _get_request_message(
+        UpdateExperiment(),
+        schema={
+            "experiment_id": [_assert_required, _assert_string],
+            "new_name": [_assert_string, _assert_required],
+        },
+    )
     if request_message.new_name:
         _get_tracking_store().rename_experiment(
             request_message.experiment_id, request_message.new_name
@@ -502,7 +641,9 @@ def _update_experiment():
 @catch_mlflow_exception
 @_disable_if_artifacts_only
 def _create_run():
-    request_message = _get_request_message(CreateRun())
+    request_message = _get_request_message(
+        CreateRun(), schema={"experiment_id": [_assert_string], "start_time": [_assert_intlike]}
+    )
 
     tags = [RunTag(tag.key, tag.value) for tag in request_message.tags]
     run = _get_tracking_store().create_run(
@@ -522,7 +663,14 @@ def _create_run():
 @catch_mlflow_exception
 @_disable_if_artifacts_only
 def _update_run():
-    request_message = _get_request_message(UpdateRun())
+    request_message = _get_request_message(
+        UpdateRun(),
+        schema={
+            "run_id": [_assert_required, _assert_string],
+            "end_time": [_assert_intlike],
+            "status": [_assert_string],
+        },
+    )
     run_id = request_message.run_id or request_message.run_uuid
     updated_info = _get_tracking_store().update_run_info(
         run_id, request_message.status, request_message.end_time
@@ -536,7 +684,9 @@ def _update_run():
 @catch_mlflow_exception
 @_disable_if_artifacts_only
 def _delete_run():
-    request_message = _get_request_message(DeleteRun())
+    request_message = _get_request_message(
+        DeleteRun(), schema={"run_id": [_assert_required, _assert_string]}
+    )
     _get_tracking_store().delete_run(request_message.run_id)
     response_message = DeleteRun.Response()
     response = Response(mimetype="application/json")
@@ -547,7 +697,9 @@ def _delete_run():
 @catch_mlflow_exception
 @_disable_if_artifacts_only
 def _restore_run():
-    request_message = _get_request_message(RestoreRun())
+    request_message = _get_request_message(
+        RestoreRun(), schema={"run_id": [_assert_required, _assert_string]}
+    )
     _get_tracking_store().restore_run(request_message.run_id)
     response_message = RestoreRun.Response()
     response = Response(mimetype="application/json")
@@ -558,7 +710,16 @@ def _restore_run():
 @catch_mlflow_exception
 @_disable_if_artifacts_only
 def _log_metric():
-    request_message = _get_request_message(LogMetric())
+    request_message = _get_request_message(
+        LogMetric(),
+        schema={
+            "run_id": [_assert_required, _assert_string],
+            "key": [_assert_required, _assert_string],
+            "value": [_assert_required, _assert_floatlike],
+            "timestamp": [_assert_intlike, _assert_required],
+            "step": [_assert_intlike],
+        },
+    )
     metric = Metric(
         request_message.key, request_message.value, request_message.timestamp, request_message.step
     )
@@ -573,7 +734,14 @@ def _log_metric():
 @catch_mlflow_exception
 @_disable_if_artifacts_only
 def _log_param():
-    request_message = _get_request_message(LogParam())
+    request_message = _get_request_message(
+        LogParam(),
+        schema={
+            "run_id": [_assert_required, _assert_string],
+            "key": [_assert_required, _assert_string],
+            "value": [_assert_required, _assert_string],
+        },
+    )
     param = Param(request_message.key, request_message.value)
     run_id = request_message.run_id or request_message.run_uuid
     _get_tracking_store().log_param(run_id, param)
@@ -586,7 +754,14 @@ def _log_param():
 @catch_mlflow_exception
 @_disable_if_artifacts_only
 def _set_experiment_tag():
-    request_message = _get_request_message(SetExperimentTag())
+    request_message = _get_request_message(
+        SetExperimentTag(),
+        schema={
+            "experiment_id": [_assert_required, _assert_string],
+            "key": [_assert_required, _assert_string],
+            "value": [_assert_required, _assert_string],
+        },
+    )
     tag = ExperimentTag(request_message.key, request_message.value)
     _get_tracking_store().set_experiment_tag(request_message.experiment_id, tag)
     response_message = SetExperimentTag.Response()
@@ -598,7 +773,14 @@ def _set_experiment_tag():
 @catch_mlflow_exception
 @_disable_if_artifacts_only
 def _set_tag():
-    request_message = _get_request_message(SetTag())
+    request_message = _get_request_message(
+        SetTag(),
+        schema={
+            "run_id": [_assert_required, _assert_string],
+            "key": [_assert_required, _assert_string],
+            "value": [_assert_required, _assert_string],
+        },
+    )
     tag = RunTag(request_message.key, request_message.value)
     run_id = request_message.run_id or request_message.run_uuid
     _get_tracking_store().set_tag(run_id, tag)
@@ -611,7 +793,13 @@ def _set_tag():
 @catch_mlflow_exception
 @_disable_if_artifacts_only
 def _delete_tag():
-    request_message = _get_request_message(DeleteTag())
+    request_message = _get_request_message(
+        DeleteTag(),
+        schema={
+            "run_id": [_assert_required, _assert_string],
+            "key": [_assert_required, _assert_string],
+        },
+    )
     _get_tracking_store().delete_tag(request_message.run_id, request_message.key)
     response_message = DeleteTag.Response()
     response = Response(mimetype="application/json")
@@ -622,7 +810,9 @@ def _delete_tag():
 @catch_mlflow_exception
 @_disable_if_artifacts_only
 def _get_run():
-    request_message = _get_request_message(GetRun())
+    request_message = _get_request_message(
+        GetRun(), schema={"run_id": [_assert_required, _assert_string]}
+    )
     response_message = GetRun.Response()
     run_id = request_message.run_id or request_message.run_uuid
     response_message.run.MergeFrom(_get_tracking_store().get_run(run_id).to_proto())
@@ -634,7 +824,16 @@ def _get_run():
 @catch_mlflow_exception
 @_disable_if_artifacts_only
 def _search_runs():
-    request_message = _get_request_message(SearchRuns())
+
+    request_message = _get_request_message(
+        SearchRuns(),
+        schema={
+            "experiment_ids": [_assert_array],
+            "filter": [_assert_string],
+            "max_results": [_assert_intlike, lambda x: _assert_less_than_or_equal(x, 50000)],
+            "order_by": [_assert_array, _assert_item_type_string],
+        },
+    )
     response_message = SearchRuns.Response()
     run_view_type = ViewType.ACTIVE_ONLY
     if request_message.HasField("run_view_type"):
@@ -658,7 +857,14 @@ def _search_runs():
 @catch_mlflow_exception
 @_disable_if_artifacts_only
 def _list_artifacts():
-    request_message = _get_request_message(ListArtifacts())
+    request_message = _get_request_message(
+        ListArtifacts(),
+        schema={
+            "run_id": [_assert_string, _assert_required],
+            "path": [_assert_string],
+            "page_token": [_assert_string],
+        },
+    )
     response_message = ListArtifacts.Response()
     if request_message.HasField("path"):
         path = request_message.path
@@ -720,7 +926,13 @@ def _list_artifacts_for_proxied_run_artifact_root(proxied_artifact_root, relativ
 @catch_mlflow_exception
 @_disable_if_artifacts_only
 def _get_metric_history():
-    request_message = _get_request_message(GetMetricHistory())
+    request_message = _get_request_message(
+        GetMetricHistory(),
+        schema={
+            "run_id": [_assert_string, _assert_required],
+            "metric_key": [_assert_string, _assert_required],
+        },
+    )
     response_message = GetMetricHistory.Response()
     run_id = request_message.run_id or request_message.run_uuid
     metric_entites = _get_tracking_store().get_metric_history(run_id, request_message.metric_key)
@@ -733,7 +945,9 @@ def _get_metric_history():
 @catch_mlflow_exception
 @_disable_if_artifacts_only
 def _list_experiments():
-    request_message = _get_request_message(ListExperiments())
+    request_message = _get_request_message(
+        ListExperiments(), schema={"max_results": [_assert_intlike], "page_token": [_assert_string]}
+    )
     # `ListFields` returns a list of (FieldDescriptor, value) tuples for *present* fields:
     # https://googleapis.dev/python/protobuf/latest/google/protobuf/message.html
     # #google.protobuf.message.Message.ListFields
@@ -756,8 +970,28 @@ def _get_artifact_repo(run):
 @catch_mlflow_exception
 @_disable_if_artifacts_only
 def _log_batch():
+    def _assert_metrics_fields_present(metrics):
+        for m in metrics:
+            _assert_required(m["key"])
+            _assert_required(m["value"])
+            _assert_required(m["timestamp"])
+            _assert_required(m["step"])
+
+    def _assert_params_tags_fields_present(params):
+        for p in params:
+            _assert_required(p["key"])
+            _assert_required(p["value"])
+
     _validate_batch_log_api_req(_get_request_json())
-    request_message = _get_request_message(LogBatch())
+    request_message = _get_request_message(
+        LogBatch(),
+        schema={
+            "run_id": [_assert_string, _assert_required],
+            "metrics": [_assert_array, _assert_metrics_fields_present],
+            "params": [_assert_array, _assert_params_tags_fields_present],
+            "tags": [_assert_array, _assert_params_tags_fields_present],
+        },
+    )
     metrics = [Metric.from_proto(proto_metric) for proto_metric in request_message.metrics]
     params = [Param.from_proto(proto_param) for proto_param in request_message.params]
     tags = [RunTag.from_proto(proto_tag) for proto_tag in request_message.tags]
@@ -773,7 +1007,13 @@ def _log_batch():
 @catch_mlflow_exception
 @_disable_if_artifacts_only
 def _log_model():
-    request_message = _get_request_message(LogModel())
+    request_message = _get_request_message(
+        LogModel(),
+        schema={
+            "run_id": [_assert_string, _assert_required],
+            "model_json": [_assert_string, _assert_required],
+        },
+    )
     try:
         model = json.loads(request_message.model_json)
     except Exception:
@@ -813,7 +1053,14 @@ def _wrap_response(response_message):
 @catch_mlflow_exception
 @_disable_if_artifacts_only
 def _create_registered_model():
-    request_message = _get_request_message(CreateRegisteredModel())
+    request_message = _get_request_message(
+        CreateRegisteredModel(),
+        schema={
+            "name": [_assert_string, _assert_required],
+            "tags": [_assert_array],
+            "description": [_assert_string],
+        },
+    )
     registered_model = _get_model_registry_store().create_registered_model(
         name=request_message.name,
         tags=request_message.tags,
@@ -826,7 +1073,9 @@ def _create_registered_model():
 @catch_mlflow_exception
 @_disable_if_artifacts_only
 def _get_registered_model():
-    request_message = _get_request_message(GetRegisteredModel())
+    request_message = _get_request_message(
+        GetRegisteredModel(), schema={"name": [_assert_string, _assert_required]}
+    )
     registered_model = _get_model_registry_store().get_registered_model(name=request_message.name)
     response_message = GetRegisteredModel.Response(registered_model=registered_model.to_proto())
     return _wrap_response(response_message)
@@ -835,7 +1084,10 @@ def _get_registered_model():
 @catch_mlflow_exception
 @_disable_if_artifacts_only
 def _update_registered_model():
-    request_message = _get_request_message(UpdateRegisteredModel())
+    request_message = _get_request_message(
+        UpdateRegisteredModel(),
+        schema={"name": [_assert_string, _assert_required], "description": [_assert_string]},
+    )
     name = request_message.name
     new_description = request_message.description
     registered_model = _get_model_registry_store().update_registered_model(
@@ -848,7 +1100,13 @@ def _update_registered_model():
 @catch_mlflow_exception
 @_disable_if_artifacts_only
 def _rename_registered_model():
-    request_message = _get_request_message(RenameRegisteredModel())
+    request_message = _get_request_message(
+        RenameRegisteredModel(),
+        schema={
+            "name": [_assert_string, _assert_required],
+            "new_name": [_assert_string, _assert_required],
+        },
+    )
     name = request_message.name
     new_name = request_message.new_name
     registered_model = _get_model_registry_store().rename_registered_model(
@@ -861,7 +1119,9 @@ def _rename_registered_model():
 @catch_mlflow_exception
 @_disable_if_artifacts_only
 def _delete_registered_model():
-    request_message = _get_request_message(DeleteRegisteredModel())
+    request_message = _get_request_message(
+        DeleteRegisteredModel(), schema={"name": [_assert_string, _assert_required]}
+    )
     _get_model_registry_store().delete_registered_model(name=request_message.name)
     return _wrap_response(DeleteRegisteredModel.Response())
 
@@ -869,7 +1129,14 @@ def _delete_registered_model():
 @catch_mlflow_exception
 @_disable_if_artifacts_only
 def _list_registered_models():
-    request_message = _get_request_message(ListRegisteredModels())
+
+    request_message = _get_request_message(
+        ListRegisteredModels(),
+        schema={
+            "max_results": [_assert_intlike, lambda x: _assert_less_than_or_equal(x, 1000)],
+            "page_token": [_assert_string],
+        },
+    )
     registered_models = _get_model_registry_store().list_registered_models(
         request_message.max_results, request_message.page_token
     )
@@ -883,7 +1150,16 @@ def _list_registered_models():
 @catch_mlflow_exception
 @_disable_if_artifacts_only
 def _search_registered_models():
-    request_message = _get_request_message(SearchRegisteredModels())
+
+    request_message = _get_request_message(
+        SearchRegisteredModels(),
+        schema={
+            "filter": [_assert_string],
+            "max_results": [_assert_intlike, lambda x: _assert_less_than_or_equal(x, 1000)],
+            "order_by": [_assert_array, _assert_item_type_string],
+            "page_token": [_assert_string],
+        },
+    )
     store = _get_model_registry_store()
     registered_models = store.search_registered_models(
         filter_string=request_message.filter,
@@ -901,7 +1177,13 @@ def _search_registered_models():
 @catch_mlflow_exception
 @_disable_if_artifacts_only
 def _get_latest_versions():
-    request_message = _get_request_message(GetLatestVersions())
+    request_message = _get_request_message(
+        GetLatestVersions(),
+        schema={
+            "name": [_assert_string, _assert_required],
+            "stages": [_assert_array, _assert_item_type_string],
+        },
+    )
     latest_versions = _get_model_registry_store().get_latest_versions(
         name=request_message.name, stages=request_message.stages
     )
@@ -913,7 +1195,14 @@ def _get_latest_versions():
 @catch_mlflow_exception
 @_disable_if_artifacts_only
 def _set_registered_model_tag():
-    request_message = _get_request_message(SetRegisteredModelTag())
+    request_message = _get_request_message(
+        SetRegisteredModelTag(),
+        schema={
+            "name": [_assert_string, _assert_required],
+            "key": [_assert_string, _assert_required],
+            "value": [_assert_string, _assert_required],
+        },
+    )
     tag = RegisteredModelTag(key=request_message.key, value=request_message.value)
     _get_model_registry_store().set_registered_model_tag(name=request_message.name, tag=tag)
     return _wrap_response(SetRegisteredModelTag.Response())
@@ -922,7 +1211,13 @@ def _set_registered_model_tag():
 @catch_mlflow_exception
 @_disable_if_artifacts_only
 def _delete_registered_model_tag():
-    request_message = _get_request_message(DeleteRegisteredModelTag())
+    request_message = _get_request_message(
+        DeleteRegisteredModelTag(),
+        schema={
+            "name": [_assert_string, _assert_required],
+            "key": [_assert_string, _assert_required],
+        },
+    )
     _get_model_registry_store().delete_registered_model_tag(
         name=request_message.name, key=request_message.key
     )
@@ -932,7 +1227,17 @@ def _delete_registered_model_tag():
 @catch_mlflow_exception
 @_disable_if_artifacts_only
 def _create_model_version():
-    request_message = _get_request_message(CreateModelVersion())
+    request_message = _get_request_message(
+        CreateModelVersion(),
+        schema={
+            "name": [_assert_string, _assert_required],
+            "source": [_assert_string, _assert_required],
+            "run_id": [_assert_string],
+            "tags": [_assert_array],
+            "run_link": [_assert_string],
+            "description": [_assert_string],
+        },
+    )
     model_version = _get_model_registry_store().create_model_version(
         name=request_message.name,
         source=request_message.source,
@@ -961,7 +1266,13 @@ def get_model_version_artifact_handler():
 @catch_mlflow_exception
 @_disable_if_artifacts_only
 def _get_model_version():
-    request_message = _get_request_message(GetModelVersion())
+    request_message = _get_request_message(
+        GetModelVersion(),
+        schema={
+            "name": [_assert_string, _assert_required],
+            "version": [_assert_string, _assert_required],
+        },
+    )
     model_version = _get_model_registry_store().get_model_version(
         name=request_message.name, version=request_message.version
     )
@@ -973,7 +1284,14 @@ def _get_model_version():
 @catch_mlflow_exception
 @_disable_if_artifacts_only
 def _update_model_version():
-    request_message = _get_request_message(UpdateModelVersion())
+    request_message = _get_request_message(
+        UpdateModelVersion(),
+        schema={
+            "name": [_assert_string, _assert_required],
+            "version": [_assert_string, _assert_required],
+            "description": [_assert_string],
+        },
+    )
     new_description = None
     if request_message.HasField("description"):
         new_description = request_message.description
@@ -986,7 +1304,15 @@ def _update_model_version():
 @catch_mlflow_exception
 @_disable_if_artifacts_only
 def _transition_stage():
-    request_message = _get_request_message(TransitionModelVersionStage())
+    request_message = _get_request_message(
+        TransitionModelVersionStage(),
+        schema={
+            "name": [_assert_string, _assert_required],
+            "version": [_assert_string, _assert_required],
+            "stage": [_assert_string, _assert_required],
+            "archive_existing_versions": [_assert_bool],
+        },
+    )
     model_version = _get_model_registry_store().transition_model_version_stage(
         name=request_message.name,
         version=request_message.version,
@@ -1001,7 +1327,13 @@ def _transition_stage():
 @catch_mlflow_exception
 @_disable_if_artifacts_only
 def _delete_model_version():
-    request_message = _get_request_message(DeleteModelVersion())
+    request_message = _get_request_message(
+        DeleteModelVersion(),
+        schema={
+            "name": [_assert_string, _assert_required],
+            "version": [_assert_string, _assert_required],
+        },
+    )
     _get_model_registry_store().delete_model_version(
         name=request_message.name, version=request_message.version
     )
@@ -1032,7 +1364,15 @@ def _search_model_versions():
 @catch_mlflow_exception
 @_disable_if_artifacts_only
 def _set_model_version_tag():
-    request_message = _get_request_message(SetModelVersionTag())
+    request_message = _get_request_message(
+        SetModelVersionTag(),
+        schema={
+            "name": [_assert_string, _assert_required],
+            "version": [_assert_string, _assert_required],
+            "key": [_assert_string, _assert_required],
+            "value": [_assert_string, _assert_required],
+        },
+    )
     tag = ModelVersionTag(key=request_message.key, value=request_message.value)
     _get_model_registry_store().set_model_version_tag(
         name=request_message.name, version=request_message.version, tag=tag
@@ -1043,7 +1383,14 @@ def _set_model_version_tag():
 @catch_mlflow_exception
 @_disable_if_artifacts_only
 def _delete_model_version_tag():
-    request_message = _get_request_message(DeleteModelVersionTag())
+    request_message = _get_request_message(
+        DeleteModelVersionTag(),
+        schema={
+            "name": [_assert_string, _assert_required],
+            "version": [_assert_string, _assert_required],
+            "key": [_assert_string, _assert_required],
+        },
+    )
     _get_model_registry_store().delete_model_version_tag(
         name=request_message.name, version=request_message.version, key=request_message.key
     )


Test patch:
diff --git a/tests/tracking/integration_test_utils.py b/tests/tracking/integration_test_utils.py
--- a/tests/tracking/integration_test_utils.py
+++ b/tests/tracking/integration_test_utils.py
@@ -82,3 +82,15 @@ def _init_server(backend_uri, root_artifact_uri):
     url = "http://{hostname}:{port}".format(hostname=LOCALHOST, port=server_port)
     _logger.info(f"Launching tracking server against backend URI {backend_uri}. Server URL: {url}")
     return url, process
+
+
+def _send_rest_tracking_post_request(tracking_server_uri, api_path, json_payload):
+    """
+    Make a POST request to the specified MLflow Tracking API and retrieve the
+    corresponding `requests.Response` object
+    """
+    import requests
+
+    url = tracking_server_uri + api_path
+    response = requests.post(url, json=json_payload)
+    return response
diff --git a/tests/tracking/test_rest_tracking.py b/tests/tracking/test_rest_tracking.py
--- a/tests/tracking/test_rest_tracking.py
+++ b/tests/tracking/test_rest_tracking.py
@@ -34,7 +34,11 @@
 from mlflow.utils.file_utils import path_to_local_file_uri
 
 from tests.integration.utils import invoke_cli_runner
-from tests.tracking.integration_test_utils import _await_server_down_or_die, _init_server
+from tests.tracking.integration_test_utils import (
+    _await_server_down_or_die,
+    _init_server,
+    _send_rest_tracking_post_request,
+)
 
 # pylint: disable=unused-argument
 
@@ -159,6 +163,41 @@ def test_create_get_list_experiment(mlflow_client):
     assert first_page_names.union(second_page_names) == {"Default", "My Experiment"}
 
 
+def test_create_experiment_validation(tracking_server_uri):
+    def assert_bad_request(payload, expected_error_message):
+        response = _send_rest_tracking_post_request(
+            tracking_server_uri,
+            "/api/2.0/mlflow/experiments/create",
+            payload,
+        )
+        assert response.status_code == 400
+        assert expected_error_message in response.text
+
+    assert_bad_request(
+        {
+            "name": 123,
+        },
+        "Invalid value 123 for parameter 'name'",
+    )
+    assert_bad_request({}, "Missing value for required parameter 'name'")
+    assert_bad_request(
+        {
+            "name": "experiment name",
+            "artifact_location": 9.0,
+            "tags": [{"key": "key", "value": "value"}],
+        },
+        "Invalid value 9.0 for parameter 'artifact_location'",
+    )
+    assert_bad_request(
+        {
+            "name": "experiment name",
+            "artifact_location": "my_location",
+            "tags": "5",
+        },
+        "Invalid value 5 for parameter 'tags'",
+    )
+
+
 def test_delete_restore_experiment(mlflow_client):
     experiment_id = mlflow_client.create_experiment("Deleterious")
     assert mlflow_client.get_experiment(experiment_id).lifecycle_stage == "active"
@@ -297,6 +336,172 @@ def test_log_metrics_params_tags(mlflow_client, backend_store_uri):
     assert metric1.step == 0
 
 
+def test_log_metric_validation(mlflow_client, tracking_server_uri):
+    experiment_id = mlflow_client.create_experiment("metrics validation")
+    created_run = mlflow_client.create_run(experiment_id)
+    run_id = created_run.info.run_id
+
+    def assert_bad_request(payload, expected_error_message):
+        response = _send_rest_tracking_post_request(
+            tracking_server_uri,
+            "/api/2.0/mlflow/runs/log-metric",
+            payload,
+        )
+        assert response.status_code == 400
+        assert expected_error_message in response.text
+
+    assert_bad_request(
+        {
+            "run_id": 31,
+            "key": "metric",
+            "value": 41,
+            "timestamp": 59,
+            "step": 26,
+        },
+        "Invalid value 31 for parameter 'run_id' supplied",
+    )
+    assert_bad_request(
+        {
+            "run_id": run_id,
+            "key": 31,
+            "value": 41,
+            "timestamp": 59,
+            "step": 26,
+        },
+        "Invalid value 31 for parameter 'key' supplied",
+    )
+    assert_bad_request(
+        {
+            "run_id": run_id,
+            "key": "foo",
+            "value": 31,
+            "timestamp": 59,
+            "step": "foo",
+        },
+        "Invalid value foo for parameter 'step' supplied",
+    )
+    assert_bad_request(
+        {
+            "run_id": run_id,
+            "key": "foo",
+            "value": 31,
+            "timestamp": "foo",
+            "step": 41,
+        },
+        "Invalid value foo for parameter 'timestamp' supplied",
+    )
+    assert_bad_request(
+        {
+            "run_id": None,
+            "key": "foo",
+            "value": 31,
+            "timestamp": 59,
+            "step": 41,
+        },
+        "Missing value for required parameter 'run_id'",
+    )
+    assert_bad_request(
+        {
+            "run_id": run_id,
+            # Missing key
+            "value": 31,
+            "timestamp": 59,
+            "step": 41,
+        },
+        "Missing value for required parameter 'key'",
+    )
+    assert_bad_request(
+        {
+            "run_id": run_id,
+            "key": None,
+            "value": 31,
+            "timestamp": 59,
+            "step": 41,
+        },
+        "Missing value for required parameter 'key'",
+    )
+
+
+def test_log_param_validation(mlflow_client, tracking_server_uri):
+    experiment_id = mlflow_client.create_experiment("params validation")
+    created_run = mlflow_client.create_run(experiment_id)
+    run_id = created_run.info.run_id
+
+    def assert_bad_request(payload, expected_error_message):
+        response = _send_rest_tracking_post_request(
+            tracking_server_uri,
+            "/api/2.0/mlflow/runs/log-parameter",
+            payload,
+        )
+        assert response.status_code == 400
+        assert expected_error_message in response.text
+
+    assert_bad_request(
+        {
+            "run_id": 31,
+            "key": "param",
+            "value": 41,
+        },
+        "Invalid value 31 for parameter 'run_id' supplied",
+    )
+    assert_bad_request(
+        {
+            "run_id": run_id,
+            "key": 31,
+            "value": 41,
+        },
+        "Invalid value 31 for parameter 'key' supplied",
+    )
+    assert_bad_request(
+        {
+            "run_id": run_id,
+            "key": "param",
+            # Missing value
+        },
+        "Missing value for required parameter 'value'",
+    )
+
+
+def test_set_tag_validation(mlflow_client, tracking_server_uri):
+    experiment_id = mlflow_client.create_experiment("tags validation")
+    created_run = mlflow_client.create_run(experiment_id)
+    run_id = created_run.info.run_id
+
+    def assert_bad_request(payload, expected_error_message):
+        response = _send_rest_tracking_post_request(
+            tracking_server_uri,
+            "/api/2.0/mlflow/runs/set-tag",
+            payload,
+        )
+        assert response.status_code == 400
+        assert expected_error_message in response.text
+
+    assert_bad_request(
+        {
+            "run_id": 31,
+            "key": "tag",
+            "value": 41,
+        },
+        "Invalid value 31 for parameter 'run_id' supplied",
+    )
+    assert_bad_request(
+        {
+            "run_id": run_id,
+            "key": "param",
+            "value": 41,
+        },
+        "Invalid value 41 for parameter 'value' supplied",
+    )
+    assert_bad_request(
+        {
+            "run_id": run_id,
+            # Missing key
+            "value": "value",
+        },
+        "Missing value for required parameter 'key'",
+    )
+
+
 def test_set_experiment_tag(mlflow_client, backend_store_uri):
     experiment_id = mlflow_client.create_experiment("SetExperimentTagTest")
     mlflow_client.set_experiment_tag(experiment_id, "dataset", "imagenet1K")
@@ -370,6 +575,30 @@ def test_log_batch(mlflow_client, backend_store_uri):
     assert metric.step == 3
 
 
+def test_log_batch_validation(mlflow_client, tracking_server_uri):
+    experiment_id = mlflow_client.create_experiment("log_batch validation")
+    created_run = mlflow_client.create_run(experiment_id)
+    run_id = created_run.info.run_id
+
+    def assert_bad_request(payload, expected_error_message):
+        response = _send_rest_tracking_post_request(
+            tracking_server_uri,
+            "/api/2.0/mlflow/runs/log-batch",
+            payload,
+        )
+        assert response.status_code == 400
+        assert expected_error_message in response.text
+
+    for request_parameter in ["metrics", "params", "tags"]:
+        assert_bad_request(
+            {
+                "run_id": run_id,
+                request_parameter: "foo",
+            },
+            f"Invalid value foo for parameter '{request_parameter}' supplied",
+        )
+
+
 @pytest.mark.allow_infer_pip_requirements_fallback
 def test_log_model(mlflow_client, backend_store_uri):
     experiment_id = mlflow_client.create_experiment("Log models")
@@ -471,6 +700,14 @@ def test_search_pagination(mlflow_client):
     assert result.token is None
 
 
+def test_search_validation(mlflow_client):
+    experiment_id = mlflow_client.create_experiment("search_validation")
+    with pytest.raises(
+        MlflowException, match=r"Invalid value 123456789 for parameter 'max_results' supplied"
+    ):
+        mlflow_client.search_runs([experiment_id], max_results=123456789)
+
+
 def test_get_experiment_by_name(mlflow_client, backend_store_uri):
     name = "test_get_experiment_by_name"
     experiment_id = mlflow_client.create_experiment(name)