Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# 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

from datetime import datetime

from airflow.api_fastapi.core_api.base import BaseModel


class DagResponse(BaseModel):
"""Schema for DAG response."""

dag_id: str
is_paused: bool
bundle_name: str | None
bundle_version: str | None
relative_fileloc: str | None
owners: str | None
tags: list[str]
next_dagrun: datetime | None
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
assets,
connections,
dag_runs,
dags,
health,
hitl,
task_instances,
Expand All @@ -43,6 +44,7 @@
authenticated_router.include_router(asset_events.router, prefix="/asset-events", tags=["Asset Events"])
authenticated_router.include_router(connections.router, prefix="/connections", tags=["Connections"])
authenticated_router.include_router(dag_runs.router, prefix="/dag-runs", tags=["Dag Runs"])
authenticated_router.include_router(dags.router, prefix="/dags", tags=["Dags"])
authenticated_router.include_router(task_instances.router, prefix="/task-instances", tags=["Task Instances"])
authenticated_router.include_router(
task_reschedules.router, prefix="/task-reschedules", tags=["Task Reschedules"]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# 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

from fastapi import APIRouter, HTTPException, status

from airflow.api_fastapi.common.db.common import SessionDep
from airflow.api_fastapi.execution_api.datamodels.dags import DagResponse
from airflow.models.dag import DagModel

router = APIRouter()


@router.get(
"/{dag_id}",
responses={
status.HTTP_404_NOT_FOUND: {"description": "DAG not found for the given dag_id"},
},
)
def get_dag(
dag_id: str,
session: SessionDep,
) -> DagResponse:
"""Get a DAG."""
dag_model: DagModel | None = session.get(DagModel, dag_id)
if not dag_model:
raise HTTPException(
status.HTTP_404_NOT_FOUND,
detail={
"reason": "not_found",
"message": f"The Dag with dag_id: `{dag_id}` was not found",
},
)

return DagResponse(
dag_id=dag_model.dag_id,
is_paused=dag_model.is_paused,
bundle_name=dag_model.bundle_name,
bundle_version=dag_model.bundle_version,
relative_fileloc=dag_model.relative_fileloc,
owners=dag_model.owners,
tags=sorted(tag.name for tag in dag_model.tags),
next_dagrun=dag_model.next_dagrun,
)
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,11 @@
ModifyDeferredTaskKwargsToJsonValue,
RemoveUpstreamMapIndexesField,
)
from airflow.api_fastapi.execution_api.versions.v2026_04_13 import AddDagEndpoint

bundle = VersionBundle(
HeadVersion(),
Version("2026-04-13", AddDagEndpoint),
Version(
"2026-03-31",
MakeDagRunStartDateNullable,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# 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

from cadwyn import VersionChange, endpoint


class AddDagEndpoint(VersionChange):
"""Add the `/dags/{dag_id}` endpoint."""

description = __doc__

instructions_to_migrate_to_previous_version = (endpoint("/dags/{dag_id}", ["GET"]).didnt_exist,)
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# 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

from datetime import datetime, timezone
from unittest.mock import ANY

import pytest
from sqlalchemy import update

from airflow.models import DagModel
from airflow.providers.standard.operators.empty import EmptyOperator

from tests_common.test_utils.db import clear_db_runs

pytestmark = pytest.mark.db_test


class TestDag:
def setup_method(self):
clear_db_runs()

def teardown_method(self):
clear_db_runs()

@pytest.mark.parametrize(
("state", "expected"),
[
pytest.param(True, True),
pytest.param(False, False),
],
)
def test_get_dag(self, client, session, dag_maker, state, expected):
"""Test getting a DAG."""

dag_id = "test_get_dag"
next_dagrun = datetime(2026, 4, 13, tzinfo=timezone.utc)

with dag_maker(dag_id=dag_id, session=session, serialized=True, tags=["z_tag", "a_tag"]):
EmptyOperator(task_id="test_task")

session.execute(
update(DagModel)
.where(DagModel.dag_id == dag_id)
.values(
is_paused=state,
bundle_version="bundle-version",
relative_fileloc="dags/example.py",
owners="owner_1",
next_dagrun=next_dagrun,
)
)

session.commit()

response = client.get(
f"/execution/dags/{dag_id}",
)

assert response.status_code == 200
assert response.json() == {
"dag_id": dag_id,
"is_paused": expected,
"bundle_name": "dag_maker",
"bundle_version": "bundle-version",
"relative_fileloc": "dags/example.py",
"owners": "owner_1",
"tags": ["a_tag", "z_tag"],
"next_dagrun": "2026-04-13T00:00:00Z",
}

def test_dag_not_found(self, client, session, dag_maker):
"""Test Dag not found"""

dag_id = "test_get_dag"

response = client.get(
f"/execution/dags/{dag_id}",
)

assert response.status_code == 404
assert response.json() == {
"detail": {
"message": "The Dag with dag_id: `test_get_dag` was not found",
"reason": "not_found",
}
}

def test_get_dag_defaults(self, client, session, dag_maker):
"""Test getting a DAG with default model values."""

dag_id = "test_get_dag_defaults"

with dag_maker(dag_id=dag_id, session=session, serialized=True):
EmptyOperator(task_id="test_task")

session.commit()

response = client.get(
f"/execution/dags/{dag_id}",
)

assert response.status_code == 200
assert response.json() == {
"dag_id": dag_id,
"is_paused": False,
"bundle_name": "dag_maker",
"bundle_version": None,
"relative_fileloc": "test_dags.py",
"owners": "airflow",
"tags": [],
"next_dagrun": ANY,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# 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

pytestmark = pytest.mark.db_test


@pytest.fixture
def old_ver_client(client):
client.headers["Airflow-API-Version"] = "2026-03-31"
return client


def test_dag_endpoint_not_available_in_previous_version(old_ver_client):
response = old_ver_client.get("/execution/dags/test_dag")

assert response.status_code == 404
2 changes: 2 additions & 0 deletions airflow-core/tests/unit/dag_processing/test_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -1911,6 +1911,7 @@ def get_type_names(union_type):
"GetAssetEventByAssetAlias",
"GetDagRun",
"GetDagRunState",
"GetDag",
"GetDRCount",
"GetTaskBreadcrumbs",
"GetTaskRescheduleStartDate",
Expand All @@ -1935,6 +1936,7 @@ def get_type_names(union_type):
in_task_runner_but_not_in_dag_processing_process = {
"AssetResult",
"AssetEventsResult",
"DagResult",
"DagRunResult",
"DagRunStateResult",
"DRCount",
Expand Down
2 changes: 2 additions & 0 deletions airflow-core/tests/unit/jobs/test_triggerer_job.py
Original file line number Diff line number Diff line change
Expand Up @@ -1317,6 +1317,7 @@ def get_type_names(union_type):
"ResendLoggingFD",
"CreateHITLDetailPayload",
"SetRenderedMapIndex",
"GetDag",
}

in_task_but_not_in_trigger_runner = {
Expand All @@ -1336,6 +1337,7 @@ def get_type_names(union_type):
"PreviousDagRunResult",
"PreviousTIResult",
"HITLDetailRequestResult",
"DagResult",
}

supervisor_diff = (
Expand Down
19 changes: 19 additions & 0 deletions task-sdk/src/airflow/sdk/api/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
AssetEventsResponse,
AssetResponse,
ConnectionResponse,
DagResponse,
DagRun,
DagRunStateResponse,
DagRunType,
Expand Down Expand Up @@ -772,6 +773,18 @@ def get_previous(
return PreviousDagRunResult(dag_run=resp.json())


class DagsOperations:
__slots__ = ("client",)

def __init__(self, client: Client):
self.client = client

def get(self, dag_id: str) -> DagResponse:
"""Get a DAG via the API server."""
resp = self.client.get(f"dags/{dag_id}")
return DagResponse.model_validate_json(resp.read())


class HITLOperations:
"""
Operations related to Human in the loop. Require Airflow 3.1+.
Expand Down Expand Up @@ -1012,6 +1025,12 @@ def hitl(self):
"""Operations related to HITL Responses."""
return HITLOperations(self)

@lru_cache() # type: ignore[misc]
@property
def dags(self) -> DagsOperations:
"""Operations related to DAGs."""
return DagsOperations(self)


# This is only used for parsing. ServerResponseError is raised instead
class _ErrorBody(BaseModel):
Expand Down
Loading
Loading