diff --git a/airflow/api_connexion/exceptions.py b/airflow/api_connexion/exceptions.py index 0360ec2b67dbb..7f3623a656ef2 100644 --- a/airflow/api_connexion/exceptions.py +++ b/airflow/api_connexion/exceptions.py @@ -29,6 +29,7 @@ EXCEPTIONS_LINK_MAP = { 400: f"{doc_link}#section/Errors/BadRequest", 404: f"{doc_link}#section/Errors/NotFound", + 405: f"{doc_link}#section/Errors/MethodNotAllowed", 401: f"{doc_link}#section/Errors/Unauthenticated", 409: f"{doc_link}#section/Errors/AlreadyExists", 403: f"{doc_link}#section/Errors/PermissionDenied", diff --git a/airflow/api_connexion/openapi/v1.yaml b/airflow/api_connexion/openapi/v1.yaml index cf84e6e2e3630..5b5a717f1f0ce 100644 --- a/airflow/api_connexion/openapi/v1.yaml +++ b/airflow/api_connexion/openapi/v1.yaml @@ -192,6 +192,8 @@ info: framing, or deceptive request routing). To resolve this, please ensure that your syntax is correct. ## NotFound This client error response indicates that the server cannot find the requested resource. + ## MethodNotAllowed + Indicates that the request method is known by the server but is not supported by the target resource. ## NotAcceptable The target resource does not have a current representation that would be acceptable to the user agent, according to the proactive negotiation header fields received in the request, and the @@ -2796,6 +2798,13 @@ components: application/json: schema: $ref: '#/components/schemas/Error' + # 405 + 'MethodNotAllowed': + description: Request method is known by the server but is not supported by the target resource. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' # 406 'NotAcceptable': description: A specified Accept header is not allowed. diff --git a/airflow/www/extensions/init_views.py b/airflow/www/extensions/init_views.py index 56753cc369bcc..11a0d083ee644 100644 --- a/airflow/www/extensions/init_views.py +++ b/airflow/www/extensions/init_views.py @@ -20,7 +20,7 @@ import connexion from connexion import ProblemException -from flask import Flask +from flask import Flask, request from airflow.api_connexion.exceptions import common_error_handler from airflow.security import permissions @@ -135,11 +135,27 @@ def init_error_handlers(app: Flask): def init_api_connexion(app: Flask) -> None: """Initialize Stable API""" + base_path = '/api/v1' + + from airflow.www import views + + @app.errorhandler(404) + @app.errorhandler(405) + def _handle_api_error(ex): + if request.path.startswith(base_path): + # 404 errors are never handled on the blueprint level + # unless raised from a view func so actual 404 errors, + # i.e. "no route for it" defined, need to be handled + # here on the application level + return common_error_handler(ex) + else: + return views.circles(ex) + spec_dir = path.join(ROOT_APP_DIR, 'api_connexion', 'openapi') connexion_app = connexion.App(__name__, specification_dir=spec_dir, skip_error_handlers=True) connexion_app.app = app api_bp = connexion_app.add_api( - specification='v1.yaml', base_path='/api/v1', validate_responses=True, strict_validation=True + specification='v1.yaml', base_path=base_path, validate_responses=True, strict_validation=True ).blueprint app.register_error_handler(ProblemException, common_error_handler) app.extensions['csrf'].exempt(api_bp) diff --git a/tests/api_connexion/test_error_handling.py b/tests/api_connexion/test_error_handling.py new file mode 100644 index 0000000000000..e921aea88f41d --- /dev/null +++ b/tests/api_connexion/test_error_handling.py @@ -0,0 +1,52 @@ +# 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 airflow.www import app + + +class TestErrorHandling(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.app = app.create_app(testing=True) # type:ignore + + def setUp(self) -> None: + self.client = self.app.test_client() # type:ignore + + def test_incorrect_endpoint_should_return_json(self): + + # Given we have application with Connexion added + # When we hitting incorrect endpoint in API path + + resp_json = self.client.get("/api/v1/incorrect_endpoint").json + + # Then we have parsable JSON as output + + self.assertEqual(404, resp_json["status"]) + + # When we are hitting non-api incorrect enpoint + + resp_json = self.client.get("/incorrect_endpoint").json + + # Then we do not have JSON as response, rather standard HTML + + self.assertIsNone(resp_json) + + resp_json = self.client.put("/api/v1/variables").json + + self.assertEqual('Method Not Allowed', resp_json["title"])