Repository: apache/airflow

Issue:
API Endpoint - CRUD - DAG
Hello 

We need to create several endpoints that perform basic CRUD operations on **DAG**. We need the following endpoints:

- PATCH /dags/{dag_id}

It depends on https://github.com/apache/airflow/issues/8128

Detailed information is available in the issue:
https://github.com/apache/airflow/issues/8118

LOVE,


Discussion:
@bertosantamaria Can you reply to this?  This allows me to assign you to this ticket.  
Yes hi and thank you.  

> On Jun 2, 2020, at 3:36 PM, Kamil Breguła <notifications@github.com> wrote:
> 
> 
> @bertosantamaria <https://github.com/bertosantamaria> Can you reply to this? This allows me to assign you to this ticket.
> 
> —
> You are receiving this because you were mentioned.
> Reply to this email directly, view it on GitHub <https://github.com/apache/airflow/issues/9109#issuecomment-637762656>, or unsubscribe <https://github.com/notifications/unsubscribe-auth/AA5P3D7S7PN3AI3674XKMCTRUVICNANCNFSM4NQ52TDA>.
> 


@mik-laj  Hi, Is this assigned to @bertosantamaria ? If not, I would like to work on this 
This ticket is blocked because it depends on read-only endpoints. - https://github.com/apache/airflow/issues/8128  These two tickets have a common scheme.
You can check available tickets in the Github project in the TODO column.
https://github.com/apache/airflow/projects/1
@mik-laj Thanks!
@mik-laj can you assign me this task please? 
@OmairK I assigned you to this ticket.

Resolving patch:
diff --git a/airflow/api_connexion/endpoints/dag_endpoint.py b/airflow/api_connexion/endpoints/dag_endpoint.py
--- a/airflow/api_connexion/endpoints/dag_endpoint.py
+++ b/airflow/api_connexion/endpoints/dag_endpoint.py
@@ -14,12 +14,13 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-from flask import current_app
+from flask import current_app, request
+from marshmallow import ValidationError
 from sqlalchemy import func
 
 from airflow import DAG
 from airflow.api_connexion import security
-from airflow.api_connexion.exceptions import NotFound
+from airflow.api_connexion.exceptions import BadRequest, NotFound
 from airflow.api_connexion.parameters import check_limit, format_parameters
 from airflow.api_connexion.schemas.dag_schema import (
     DAGCollection, dag_detail_schema, dag_schema, dags_collection_schema,
@@ -70,8 +71,19 @@ def get_dags(session, limit, offset=0):
 
 
 @security.requires_authentication
-def patch_dag():
+@provide_session
+def patch_dag(session, dag_id):
     """
     Update the specific DAG
     """
-    raise NotImplementedError("Not implemented yet.")
+    dag = session.query(DagModel).filter(DagModel.dag_id == dag_id).one_or_none()
+    if not dag:
+        raise NotFound(f"Dag with id: '{dag_id}' not found")
+    try:
+        patch_body = dag_schema.load(request.json, session=session)
+    except ValidationError as err:
+        raise BadRequest("Invalid Dag schema", detail=str(err.messages))
+    for key, value in patch_body.items():
+        setattr(dag, key, value)
+    session.commit()
+    return dag_schema.dump(dag)
diff --git a/airflow/api_connexion/schemas/common_schema.py b/airflow/api_connexion/schemas/common_schema.py
--- a/airflow/api_connexion/schemas/common_schema.py
+++ b/airflow/api_connexion/schemas/common_schema.py
@@ -83,7 +83,7 @@ def make_relative_delta(self, data, **kwargs):
 class CronExpressionSchema(Schema):
     """Cron expression schema"""
 
-    objectType = fields.Constant("CronExpression", data_key="__type", required=True)
+    objectType = fields.Constant("CronExpression", data_key="__type")
     value = fields.String(required=True)
 
     @marshmallow.post_load
diff --git a/airflow/api_connexion/schemas/dag_schema.py b/airflow/api_connexion/schemas/dag_schema.py
--- a/airflow/api_connexion/schemas/dag_schema.py
+++ b/airflow/api_connexion/schemas/dag_schema.py
@@ -43,12 +43,12 @@ class Meta:
 
     dag_id = auto_field(dump_only=True)
     root_dag_id = auto_field(dump_only=True)
-    is_paused = auto_field(dump_only=True)
+    is_paused = auto_field()
     is_subdag = auto_field(dump_only=True)
     fileloc = auto_field(dump_only=True)
     owners = fields.Method("get_owners", dump_only=True)
     description = auto_field(dump_only=True)
-    schedule_interval = fields.Nested(ScheduleIntervalSchema, dump_only=True)
+    schedule_interval = fields.Nested(ScheduleIntervalSchema)
     tags = fields.List(fields.Nested(DagTagSchema), dump_only=True)
 
     @staticmethod


Test patch:
diff --git a/tests/api_connexion/endpoints/test_dag_endpoint.py b/tests/api_connexion/endpoints/test_dag_endpoint.py
--- a/tests/api_connexion/endpoints/test_dag_endpoint.py
+++ b/tests/api_connexion/endpoints/test_dag_endpoint.py
@@ -18,7 +18,6 @@
 import unittest
 from datetime import datetime
 
-import pytest
 from parameterized import parameterized
 
 from airflow import DAG
@@ -77,7 +76,7 @@ def _create_dag_models(self, count, session=None):
             dag_model = DagModel(
                 dag_id=f"TEST_DAG_{num}",
                 fileloc=f"/tmp/dag_{num}.py",
-                schedule_interval="2 2 * * *"
+                schedule_interval="2 2 * * *",
             )
             session.add(dag_model)
 
@@ -90,17 +89,20 @@ def test_should_response_200(self):
 
         current_response = response.json
         current_response["fileloc"] = "/tmp/test-dag.py"
-        self.assertEqual({
-            'dag_id': 'TEST_DAG_1',
-            'description': None,
-            'fileloc': '/tmp/test-dag.py',
-            'is_paused': False,
-            'is_subdag': False,
-            'owners': [],
-            'root_dag_id': None,
-            'schedule_interval': {'__type': 'CronExpression', 'value': '2 2 * * *'},
-            'tags': []
-        }, current_response)
+        self.assertEqual(
+            {
+                "dag_id": "TEST_DAG_1",
+                "description": None,
+                "fileloc": "/tmp/test-dag.py",
+                "is_paused": False,
+                "is_subdag": False,
+                "owners": [],
+                "root_dag_id": None,
+                "schedule_interval": {"__type": "CronExpression", "value": "2 2 * * *"},
+                "tags": [],
+            },
+            current_response,
+        )
 
     def test_should_response_404(self):
         response = self.client.get("/api/v1/dags/INVALID_DAG", environ_overrides={'REMOTE_USER': "test"})
@@ -121,27 +123,27 @@ def test_should_response_200(self):
         )
         assert response.status_code == 200
         expected = {
-            'catchup': True,
-            'concurrency': 16,
-            'dag_id': 'test_dag',
-            'dag_run_timeout': None,
-            'default_view': 'tree',
-            'description': None,
-            'doc_md': 'details',
-            'fileloc': __file__,
-            'is_paused': None,
-            'is_subdag': False,
-            'orientation': 'LR',
-            'owners': [],
-            'schedule_interval': {
-                '__type': 'TimeDelta',
-                'days': 1,
-                'microseconds': 0,
-                'seconds': 0
+            "catchup": True,
+            "concurrency": 16,
+            "dag_id": "test_dag",
+            "dag_run_timeout": None,
+            "default_view": "tree",
+            "description": None,
+            "doc_md": "details",
+            "fileloc": __file__,
+            "is_paused": None,
+            "is_subdag": False,
+            "orientation": "LR",
+            "owners": [],
+            "schedule_interval": {
+                "__type": "TimeDelta",
+                "days": 1,
+                "microseconds": 0,
+                "seconds": 0,
             },
-            'start_date': '2020-06-15T00:00:00+00:00',
-            'tags': None,
-            'timezone': "Timezone('UTC')"
+            "start_date": "2020-06-15T00:00:00+00:00",
+            "tags": None,
+            "timezone": "Timezone('UTC')",
         }
         assert response.json == expected
 
@@ -155,27 +157,27 @@ def test_should_response_200_serialized(self):
         SerializedDagModel.write_dag(self.dag)
 
         expected = {
-            'catchup': True,
-            'concurrency': 16,
-            'dag_id': 'test_dag',
-            'dag_run_timeout': None,
-            'default_view': 'tree',
-            'description': None,
-            'doc_md': 'details',
-            'fileloc': __file__,
-            'is_paused': None,
-            'is_subdag': False,
-            'orientation': 'LR',
-            'owners': [],
-            'schedule_interval': {
-                '__type': 'TimeDelta',
-                'days': 1,
-                'microseconds': 0,
-                'seconds': 0
+            "catchup": True,
+            "concurrency": 16,
+            "dag_id": "test_dag",
+            "dag_run_timeout": None,
+            "default_view": "tree",
+            "description": None,
+            "doc_md": "details",
+            "fileloc": __file__,
+            "is_paused": None,
+            "is_subdag": False,
+            "orientation": "LR",
+            "owners": [],
+            "schedule_interval": {
+                "__type": "TimeDelta",
+                "days": 1,
+                "microseconds": 0,
+                "seconds": 0,
             },
-            'start_date': '2020-06-15T00:00:00+00:00',
-            'tags': None,
-            'timezone': "Timezone('UTC')"
+            "start_date": "2020-06-15T00:00:00+00:00",
+            "tags": None,
+            "timezone": "Timezone('UTC')",
         }
         response = client.get(
             f"/api/v1/dags/{self.dag_id}/details", environ_overrides={'REMOTE_USER': "test"}
@@ -219,7 +221,6 @@ def test_should_raises_401_unauthenticated(self):
 
 
 class TestGetDags(TestDagEndpoint):
-
     def test_should_response_200(self):
         self._create_dag_models(2)
 
@@ -238,7 +239,10 @@ def test_should_response_200(self):
                         "is_subdag": False,
                         "owners": [],
                         "root_dag_id": None,
-                        "schedule_interval": {"__type": "CronExpression", "value": "2 2 * * *"},
+                        "schedule_interval": {
+                            "__type": "CronExpression",
+                            "value": "2 2 * * *",
+                        },
                         "tags": [],
                     },
                     {
@@ -249,7 +253,10 @@ def test_should_response_200(self):
                         "is_subdag": False,
                         "owners": [],
                         "root_dag_id": None,
-                        "schedule_interval": {"__type": "CronExpression", "value": "2 2 * * *"},
+                        "schedule_interval": {
+                            "__type": "CronExpression",
+                            "value": "2 2 * * *",
+                        },
                         "tags": [],
                     },
                 ],
@@ -264,13 +271,7 @@ def test_should_response_200(self):
             ("api/v1/dags?limit=2", ["TEST_DAG_1", "TEST_DAG_10"]),
             (
                 "api/v1/dags?offset=5",
-                [
-                    "TEST_DAG_5",
-                    "TEST_DAG_6",
-                    "TEST_DAG_7",
-                    "TEST_DAG_8",
-                    "TEST_DAG_9",
-                ],
+                ["TEST_DAG_5", "TEST_DAG_6", "TEST_DAG_7", "TEST_DAG_8", "TEST_DAG_9"],
             ),
             (
                 "api/v1/dags?offset=0",
@@ -299,10 +300,10 @@ def test_should_response_200_and_handle_pagination(self, url, expected_dag_ids):
 
         assert response.status_code == 200
 
-        dag_ids = [dag["dag_id"] for dag in response.json['dags']]
+        dag_ids = [dag["dag_id"] for dag in response.json["dags"]]
 
         self.assertEqual(expected_dag_ids, dag_ids)
-        self.assertEqual(10, response.json['total_entries'])
+        self.assertEqual(10, response.json["total_entries"])
 
     def test_should_response_200_default_limit(self):
         self._create_dag_models(101)
@@ -311,8 +312,8 @@ def test_should_response_200_default_limit(self):
 
         assert response.status_code == 200
 
-        self.assertEqual(100, len(response.json['dags']))
-        self.assertEqual(101, response.json['total_entries'])
+        self.assertEqual(100, len(response.json["dags"]))
+        self.assertEqual(101, response.json["total_entries"])
 
     def test_should_raises_401_unauthenticated(self):
         response = self.client.get("api/v1/dags")
@@ -321,13 +322,72 @@ def test_should_raises_401_unauthenticated(self):
 
 
 class TestPatchDag(TestDagEndpoint):
-    @pytest.mark.skip(reason="Not implemented yet")
-    def test_should_response_200(self):
-        response = self.client.patch("/api/v1/dags/1", environ_overrides={'REMOTE_USER': "test"})
-        assert response.status_code == 200
+    def test_should_response_200_on_patch_is_paused(self):
+        dag_model = self._create_dag_model()
+        response = self.client.patch(
+            f"/api/v1/dags/{dag_model.dag_id}",
+            json={
+                "is_paused": False,
+            },
+            environ_overrides={'REMOTE_USER': "test"}
+        )
+        self.assertEqual(response.status_code, 200)
+        expected_response = {
+            "dag_id": "TEST_DAG_1",
+            "description": None,
+            "fileloc": "/tmp/dag_1.py",
+            "is_paused": False,
+            "is_subdag": False,
+            "owners": [],
+            "root_dag_id": None,
+            "schedule_interval": {
+                "__type": "CronExpression",
+                "value": "2 2 * * *",
+            },
+            "tags": [],
+        }
+        self.assertEqual(response.json, expected_response)
+
+    def test_should_response_400_on_invalid_request(self):
+        patch_body = {
+            "is_paused": True,
+            "schedule_interval": {
+                "__type": "CronExpression",
+                "value": "1 1 * * *",
+            },
+        }
+        dag_model = self._create_dag_model()
+        response = self.client.patch(f"/api/v1/dags/{dag_model.dag_id}", json=patch_body)
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json, {
+            'detail': "Property is read-only - 'schedule_interval'",
+            'status': 400,
+            'title': 'Bad Request',
+            'type': 'about:blank'
+        })
+
+    def test_should_response_404(self):
+        response = self.client.get("/api/v1/dags/INVALID_DAG", environ_overrides={'REMOTE_USER': "test"})
+        self.assertEqual(response.status_code, 404)
+
+    @provide_session
+    def _create_dag_model(self, session=None):
+        dag_model = DagModel(
+            dag_id="TEST_DAG_1",
+            fileloc="/tmp/dag_1.py",
+            schedule_interval="2 2 * * *",
+            is_paused=True
+        )
+        session.add(dag_model)
+        return dag_model
 
-    @pytest.mark.skip(reason="Not implemented yet")
     def test_should_raises_401_unauthenticated(self):
-        response = self.client.patch("/api/v1/dags/1")
+        dag_model = self._create_dag_model()
+        response = self.client.patch(
+            f"/api/v1/dags/{dag_model.dag_id}",
+            json={
+                "is_paused": False,
+            },
+        )
 
         assert_401(response)