diff --git a/airflow/utils/docs.py b/airflow/utils/docs.py new file mode 100644 index 0000000000..6d3b4d3d8c --- /dev/null +++ b/airflow/utils/docs.py @@ -0,0 +1,31 @@ +# 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 typing import Optional + +from airflow import version + + +def get_docs_url(page: Optional[str] = None) -> str: + """Prepare link to Airflow documentation.""" + if "dev" in version.version: + result = "https://airflow.readthedocs.io/en/latest/" + else: + result = 'https://airflow.apache.org/docs/{}/'.format(version.version) + if page: + result = result + page + return result diff --git a/airflow/www/api/experimental/endpoints.py b/airflow/www/api/experimental/endpoints.py index 94ca251948..023ab72e58 100644 --- a/airflow/www/api/experimental/endpoints.py +++ b/airflow/www/api/experimental/endpoints.py @@ -19,7 +19,7 @@ import logging from functools import wraps from typing import Callable, TypeVar, cast -from flask import Blueprint, current_app, g, jsonify, request, url_for +from flask import Blueprint, Response, current_app, g, jsonify, request, url_for from airflow import models from airflow.api.common.experimental import delete_dag as delete, pool as pool_api, trigger_dag as trigger @@ -31,6 +31,7 @@ from airflow.api.common.experimental.get_task import get_task from airflow.api.common.experimental.get_task_instance import get_task_instance from airflow.exceptions import AirflowException from airflow.utils import timezone +from airflow.utils.docs import get_docs_url from airflow.utils.strings import to_boolean from airflow.version import version @@ -51,6 +52,25 @@ def requires_authentication(function: T): api_experimental = Blueprint('api_experimental', __name__) +def add_deprecation_headers(response: Response): + """ + Add `Deprecation HTTP Header Field + `__. + """ + response.headers['Deprecation'] = 'true' + doc_url = get_docs_url("stable-rest-api/migration.html") + deprecation_link = f'<{doc_url}>; rel="deprecation"; type="text/html"' + if 'link' in response.headers: + response.headers['Link'] += f', {deprecation_link}' + else: + response.headers['Link'] = f'{deprecation_link}' + + return response + + +api_experimental.after_request(add_deprecation_headers) + + @api_experimental.route('/dags//dag_runs', methods=['POST']) @requires_authentication def trigger_dag(dag_id): diff --git a/airflow/www/extensions/init_appbuilder_links.py b/airflow/www/extensions/init_appbuilder_links.py index e55b0c0aea..14800f15e9 100644 --- a/airflow/www/extensions/init_appbuilder_links.py +++ b/airflow/www/extensions/init_appbuilder_links.py @@ -15,21 +15,17 @@ # specific language governing permissions and limitations # under the License. -from airflow import version +from airflow.utils.docs import get_docs_url def init_appbuilder_links(app): """Add links to Docs menu in navbar""" appbuilder = app.appbuilder - if "dev" in version.version: - doc_site = "https://airflow.readthedocs.io/en/latest" - else: - doc_site = 'https://airflow.apache.org/docs/{}'.format(version.version) appbuilder.add_link( "Website", href='https://airflow.apache.org', category="Docs", category_icon="fa-globe" ) - appbuilder.add_link("Documentation", href=doc_site, category="Docs", category_icon="fa-cube") + appbuilder.add_link("Documentation", href=get_docs_url(), category="Docs", category_icon="fa-cube") appbuilder.add_link("GitHub", href='https://github.com/apache/airflow', category="Docs") appbuilder.add_link( "REST API Reference (Swagger UI)", href='/api/v1./api/v1_swagger_ui_index', category="Docs" diff --git a/docs/index.rst b/docs/index.rst index 4c7494275a..7bc223dccc 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -95,8 +95,8 @@ Content kubernetes lineage dag-serialization - Using the REST API - REST API Migration Guide + Using the REST API + REST API Migration Guide changelog best-practices faq diff --git a/docs/rest-api-ref.rst b/docs/rest-api-ref.rst index 4456e49932..25e8032501 100644 --- a/docs/rest-api-ref.rst +++ b/docs/rest-api-ref.rst @@ -21,9 +21,10 @@ Experimental REST API Reference Airflow exposes an REST API. It is available through the webserver. Endpoints are available at ``/api/experimental/``. -.. warning:: +.. deprecated:: 2.0 - The API structure is not stable. We expect the endpoint definitions to change. + This REST API is deprecated. Please consider using :doc:`the stable REST API `. + For more information on migration, see: :doc:`stable-rest-api/migration`. Endpoints --------- diff --git a/tests/utils/test_docs.py b/tests/utils/test_docs.py new file mode 100644 index 0000000000..8a86bcada5 --- /dev/null +++ b/tests/utils/test_docs.py @@ -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. + +import unittest +from unittest import mock + +from parameterized import parameterized + +from airflow.utils.docs import get_docs_url + + +class TestGetDocsUrl(unittest.TestCase): + @parameterized.expand([ + ('2.0.0.dev0', None, 'https://airflow.readthedocs.io/en/latest/'), + ('2.0.0.dev0', 'migration.html', 'https://airflow.readthedocs.io/en/latest/migration.html'), + ('1.10.0', None, 'https://airflow.apache.org/docs/1.10.0/'), + ('1.10.0', 'migration.html', 'https://airflow.apache.org/docs/1.10.0/migration.html'), + ]) + def test_should_return_link(self, version, page, expected_urk): + with mock.patch('airflow.version.version', version): + self.assertEqual(expected_urk, get_docs_url(page)) diff --git a/tests/www/api/experimental/test_endpoints.py b/tests/www/api/experimental/test_endpoints.py index bef620d9c7..1903efe30d 100644 --- a/tests/www/api/experimental/test_endpoints.py +++ b/tests/www/api/experimental/test_endpoints.py @@ -52,6 +52,14 @@ class TestBase(unittest.TestCase): settings.configure_orm() self.session = Session + def assert_deprecated(self, resp): + self.assertEqual('true', resp.headers['Deprecation']) + self.assertRegex( + resp.headers['Link'], + r'\<.+/stable-rest-api/migration.html\>; ' + 'rel="deprecation"; type="text/html"', + ) + @parameterized_class([ {"dag_serialization": "False"}, @@ -89,6 +97,7 @@ class TestApiExperimental(TestBase): resp = json.loads(resp_raw.data.decode('utf-8')) self.assertEqual(version, resp['version']) + self.assert_deprecated(resp_raw) def test_task_info(self): with conf_vars( @@ -99,6 +108,8 @@ class TestApiExperimental(TestBase): response = self.client.get( url_template.format('example_bash_operator', 'runme_0') ) + self.assert_deprecated(response) + self.assertIn('"email"', response.data.decode('utf-8')) self.assertNotIn('error', response.data.decode('utf-8')) self.assertEqual(200, response.status_code) @@ -124,6 +135,7 @@ class TestApiExperimental(TestBase): response = self.client.get( url_template.format('example_bash_operator') ) + self.assert_deprecated(response) self.assertIn('BashOperator(', response.data.decode('utf-8')) self.assertEqual(200, response.status_code) @@ -143,6 +155,7 @@ class TestApiExperimental(TestBase): response = self.client.get( pause_url_template.format('example_bash_operator', 'true') ) + self.assert_deprecated(response) self.assertIn('ok', response.data.decode('utf-8')) self.assertEqual(200, response.status_code) @@ -173,6 +186,7 @@ class TestApiExperimental(TestBase): data=json.dumps({'run_id': run_id}), content_type="application/json" ) + self.assert_deprecated(response) self.assertEqual(200, response.status_code) response_execution_date = parse_datetime( @@ -211,6 +225,7 @@ class TestApiExperimental(TestBase): data=json.dumps({'execution_date': datetime_string}), content_type="application/json" ) + self.assert_deprecated(response) self.assertEqual(200, response.status_code) self.assertEqual(datetime_string, json.loads(response.data.decode('utf-8'))['execution_date']) @@ -277,6 +292,7 @@ class TestApiExperimental(TestBase): response = self.client.get( url_template.format(dag_id, datetime_string, task_id) ) + self.assert_deprecated(response) self.assertEqual(200, response.status_code) self.assertIn('state', response.data.decode('utf-8')) self.assertNotIn('error', response.data.decode('utf-8')) @@ -331,6 +347,7 @@ class TestApiExperimental(TestBase): response = self.client.get( url_template.format(dag_id, datetime_string) ) + self.assert_deprecated(response) self.assertEqual(200, response.status_code) self.assertIn('state', response.data.decode('utf-8')) self.assertNotIn('error', response.data.decode('utf-8')) @@ -401,6 +418,7 @@ class TestLineageApiExperimental(TestBase): response = self.client.get( url_template.format(dag_id, datetime_string) ) + self.assert_deprecated(response) self.assertEqual(200, response.status_code) self.assertIn('task_ids', response.data.decode('utf-8')) self.assertNotIn('error', response.data.decode('utf-8')) @@ -461,6 +479,7 @@ class TestPoolApiExperimental(TestBase): response = self.client.get( '/api/experimental/pools/{}'.format(self.pool.pool), ) + self.assert_deprecated(response) self.assertEqual(response.status_code, 200) self.assertEqual(json.loads(response.data.decode('utf-8')), self.pool.to_json()) @@ -473,6 +492,7 @@ class TestPoolApiExperimental(TestBase): def test_get_pools(self): response = self.client.get('/api/experimental/pools') + self.assert_deprecated(response) self.assertEqual(response.status_code, 200) pools = json.loads(response.data.decode('utf-8')) self.assertEqual(len(pools), self.TOTAL_POOL_COUNT) @@ -489,6 +509,7 @@ class TestPoolApiExperimental(TestBase): }), content_type='application/json', ) + self.assert_deprecated(response) self.assertEqual(response.status_code, 200) pool = json.loads(response.data.decode('utf-8')) self.assertEqual(pool['pool'], 'foo') @@ -518,6 +539,7 @@ class TestPoolApiExperimental(TestBase): response = self.client.delete( '/api/experimental/pools/{}'.format(self.pool.pool), ) + self.assert_deprecated(response) self.assertEqual(response.status_code, 200) self.assertEqual(json.loads(response.data.decode('utf-8')), self.pool.to_json())