Repository: apache/airflow

Issue:
UI doesn't handle whitespace/empty dataset URI's well
### Apache Airflow version

main (development)

### What happened

Here are some poor choices for dataset URI's:

```python3
empty = Dataset("")
colons = Dataset("::::::")
whitespace = Dataset("\t\n")
emoji = Dataset("😊")
long = Dataset(5000 * "x")
injection = Dataset("105'; DROP TABLE 'dag")
```

And a dag file which replicates the problems mentioned below: https://gist.github.com/MatrixManAtYrService/a32bba5d382cd9a925da72571772b060 (full tracebacks included as comments)

Here's how they did:
|dataset|behavior|
|:-:|:--|
|empty| dag triggered with no trouble, not selectable in the datasets UI|
|emoji| `airflow dags reserialize`: `UnicodeEncodeError: 'ascii' codec can't encode character '\U0001f60a' in position 0: ordinal not in range(128)`|
|colons| no trouble|
|whitespace| dag triggered with no trouble, selectable in the datasets UI, but shows no history|
|long|sqlalchemy error during serialization|
|injection| no trouble|

Finally, here's a screenshot:

<img width="1431" alt="Screen Shot 2022-09-13 at 11 29 02 PM" src="https://user-images.githubusercontent.com/5834582/190069341-dc17c66a-f941-424d-a455-cd531580543a.png">

Notice that there are two empty rows in the datasets list, one for `empty`, the other for `whitespace`.  Only `whitespace` is selectable, both look weird.

### What you think should happen instead

I propose that we add a uri sanity check during serialization and just reject dataset URI's that are:
- only whitespace
- empty
- long enough that they're going to cause a database problem

The `emoji` case failed in a nice way.  Ideally `whitespace`, `long` and `empty` can fail in the same way.  If implemented, this would prevent any of the weird cases above from making it to the UI in the first place.


### How to reproduce

Unpause the above dags

### Operating System

Docker/debian

### Versions of Apache Airflow Providers

n/a

### Deployment

Astronomer

### Deployment details

`astro dev start`

### Anything else

_No response_

### Are you willing to submit PR?

- [ ] Yes I am willing to submit a PR!

### Code of Conduct

- [X] I agree to follow this project's [Code of Conduct](https://github.com/apache/airflow/blob/main/CODE_OF_CONDUCT.md)



Discussion:

Resolving patch:
diff --git a/airflow/datasets/__init__.py b/airflow/datasets/__init__.py
--- a/airflow/datasets/__init__.py
+++ b/airflow/datasets/__init__.py
@@ -17,6 +17,7 @@
 from __future__ import annotations
 
 from typing import Any
+from urllib.parse import urlparse
 
 import attr
 
@@ -25,5 +26,17 @@
 class Dataset:
     """A Dataset is used for marking data dependencies between workflows."""
 
-    uri: str
+    uri: str = attr.field(validator=[attr.validators.min_len(1), attr.validators.max_len(3000)])
     extra: dict[str, Any] | None = None
+
+    @uri.validator
+    def _check_uri(self, attr, uri: str):
+        if uri.isspace():
+            raise ValueError(f'{attr.name} cannot be just whitespace')
+        try:
+            uri.encode('ascii')
+        except UnicodeEncodeError:
+            raise ValueError(f'{attr.name!r} must be ascii')
+        parsed = urlparse(uri)
+        if parsed.scheme and parsed.scheme.lower() == 'airflow':
+            raise ValueError(f'{attr.name!r} scheme `airflow` is reserved')


Test patch:
diff --git a/tests/datasets/test_dataset.py b/tests/datasets/test_dataset.py
new file mode 100644
--- /dev/null
+++ b/tests/datasets/test_dataset.py
@@ -0,0 +1,48 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import annotations
+
+import pytest
+
+from airflow.datasets import Dataset
+from airflow.operators.empty import EmptyOperator
+
+
+@pytest.mark.parametrize(
+    ["uri"],
+    [
+        pytest.param("", id="empty"),
+        pytest.param("\n\t", id="whitespace"),
+        pytest.param("a" * 3001, id="too_long"),
+        pytest.param("airflow:" * 3001, id="reserved_scheme"),
+        pytest.param("😊" * 3001, id="non-ascii"),
+    ],
+)
+def test_invalid_uris(uri):
+    with pytest.raises(ValueError):
+        Dataset(uri=uri)
+
+
+def test_uri_with_scheme():
+    dataset = Dataset(uri="s3://example_dataset")
+    EmptyOperator(task_id="task1", outlets=[dataset])
+
+
+def test_uri_without_scheme():
+    dataset = Dataset(uri="example_dataset")
+    EmptyOperator(task_id="task1", outlets=[dataset])
diff --git a/tests/models/test_dataset.py b/tests/models/test_dataset.py
deleted file mode 100644
--- a/tests/models/test_dataset.py
+++ /dev/null
@@ -1,46 +0,0 @@
-# Licensed to the Apache Software Foundation (ASF) under one
-# or more contributor license agreements.  See the NOTICE file
-# distributed with this work for additional information
-# regarding copyright ownership.  The ASF licenses this file
-# to you under the Apache License, Version 2.0 (the
-# "License"); you may not use this file except in compliance
-# with the License.  You may obtain a copy of the License at
-#
-#   http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing,
-# software distributed under the License is distributed on an
-# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-# KIND, either express or implied.  See the License for the
-# specific language governing permissions and limitations
-# under the License.
-from __future__ import annotations
-
-import pytest
-
-from airflow.datasets import Dataset
-from airflow.operators.empty import EmptyOperator
-
-
-class TestDataset:
-    def test_uri_without_scheme(self, dag_maker):
-        dataset = Dataset(uri="example_dataset")
-        with dag_maker(dag_id="example_dataset"):
-            EmptyOperator(task_id="task1", outlets=[dataset])
-
-    def test_uri_with_scheme(self, dag_maker, session):
-        dataset = Dataset(uri="s3://example_dataset")
-        with dag_maker(dag_id="example_dataset"):
-            EmptyOperator(task_id="task1", outlets=[dataset])
-
-    def test_uri_with_airflow_scheme_restricted(self, dag_maker, session):
-        dataset = Dataset(uri="airflow://example_dataset")
-        with pytest.raises(ValueError, match='Scheme `airflow` is reserved'):
-            with dag_maker(dag_id="example_dataset"):
-                EmptyOperator(task_id="task1", outlets=[dataset])
-
-    def test_uri_with_invalid_characters(self, dag_maker, session):
-        dataset = Dataset(uri="èxample_datašet")
-        with pytest.raises(ValueError, match='URI must be ascii'):
-            with dag_maker(dag_id="example_dataset"):
-                EmptyOperator(task_id="task1", outlets=[dataset])