├── extended_api
├── VERSION
├── __init__.py
├── constants.py
├── airflow_extended_api.py
├── service
│ ├── executor.py
│ └── plugin_service.py
├── view
│ └── plugin_view.py
├── model
│ └── models.py
└── static
│ └── openapi.json
├── requirements.txt
├── .gitattributes
├── MANIFEST.in
├── pics
└── img.png
├── .gitignore
├── setup.py
├── .github
└── workflows
│ └── release.yml
├── README_CN.md
├── README.md
└── LICENSE
/extended_api/VERSION:
--------------------------------------------------------------------------------
1 | 1.1.3
--------------------------------------------------------------------------------
/extended_api/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | flask-swagger-ui
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | *.html linguist-language=python
2 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | recursive-include extended_api *.py *.json
2 | include requirements.txt
--------------------------------------------------------------------------------
/pics/img.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/caoergou/airflow-extended-api-plugin/HEAD/pics/img.png
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /build/*
2 | /.idea/*
3 | /dist/*
4 | /dags/*
5 | /plugins/*
6 | /airflow_extended_api.egg-info/*
7 | /docker-compose.yaml
8 | /Dockerfile
9 |
--------------------------------------------------------------------------------
/extended_api/constants.py:
--------------------------------------------------------------------------------
1 | from os import path as opath
2 |
3 | PLUGIN_NAME = 'extended_api_plugin'
4 | PLUGIN_FULL_NAME = "Airflow Extended API Plugin"
5 |
6 | PLUGIN_AUTHOR = "Eric Cao"
7 | AUTHOR_EMAIL = "itsericsmail@gmail.com"
8 |
9 | ROUTE = "/api/extended"
10 | DOCS_ROUTE = "/api/extended/docs"
11 | OPENAPI_ROUTE = "/api/extended/openapi"
12 |
13 | DOCS = "Docs"
14 | DOCS_ITEM = "Extended API OpenAPI"
15 | INFO_ITEM = "about Extended API"
16 | GITHUB_REPO_URL = "https://www.github.com/caoergou/airflow_extend_api_plugin"
17 |
18 | PLUGIN_DIR_PATH = opath.dirname(opath.abspath(__file__))
19 | PLUGIN_STATIC_DIR_PATH = opath.join(PLUGIN_DIR_PATH, 'static')
20 |
--------------------------------------------------------------------------------
/extended_api/airflow_extended_api.py:
--------------------------------------------------------------------------------
1 | from airflow.plugins_manager import AirflowPlugin
2 | from flask_swagger_ui import get_swaggerui_blueprint
3 |
4 | from extended_api.constants import DOCS, INFO_ITEM, GITHUB_REPO_URL, PLUGIN_FULL_NAME, PLUGIN_AUTHOR, AUTHOR_EMAIL, \
5 | PLUGIN_NAME, DOCS_ROUTE, OPENAPI_ROUTE
6 |
7 | __author__ = f'{PLUGIN_AUTHOR} <{AUTHOR_EMAIL}>'
8 |
9 | from extended_api.view.plugin_view import appbuilder_view
10 |
11 | bp = get_swaggerui_blueprint(
12 | DOCS_ROUTE,
13 | OPENAPI_ROUTE,
14 | config={'app_name': PLUGIN_FULL_NAME}
15 | )
16 |
17 | api_info = {
18 | "category": DOCS,
19 | "name": INFO_ITEM,
20 | "href": GITHUB_REPO_URL
21 | }
22 |
23 |
24 | class ExtendedAPIPlugin(AirflowPlugin):
25 | name = PLUGIN_NAME
26 | appbuilder_views = [appbuilder_view]
27 | flask_blueprints = [bp]
28 | appbuilder_menu_items = [api_info]
29 |
--------------------------------------------------------------------------------
/extended_api/service/executor.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import os
3 | import subprocess
4 | from typing import List
5 |
6 | from airflow.api_connexion.exceptions import BadRequest
7 |
8 | log = logging.getLogger(__name__)
9 |
10 |
11 | def execute_cli_command(cmd_list: List[str], username: str = "Airflow Extended API") -> dict:
12 | cmd_str: str = ' '.join(cmd_list)
13 | log.warning(f"Executing CLI Command {cmd_str}")
14 | os.environ.setdefault("LOGNAME", username)
15 | try:
16 | process = subprocess.Popen(cmd_list, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
17 | out, err = process.communicate()
18 | exit_code = process.returncode
19 | except Exception as e:
20 | log.error("An error occurred while trying to executing run cli command")
21 | raise BadRequest(detail=str(e))
22 |
23 | original_result = {
24 | "executed_command": cmd_str,
25 | "output_info": out.decode('utf-8'),
26 | "error_info": err.decode('utf-8'),
27 | "exit_code": exit_code
28 | }
29 | return original_result
30 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | from os import path as opath
2 |
3 | from setuptools import setup
4 |
5 | HERE = opath.dirname(__file__)
6 | VERSION_FILE = opath.join(HERE, 'extended_api', 'VERSION')
7 | README_FILE = opath.join(HERE, 'README.md')
8 |
9 | with open(VERSION_FILE) as f:
10 | version = f.read().strip()
11 |
12 | with open(README_FILE, "r") as f:
13 | long_description = f.read()
14 |
15 | setup(
16 | name="airflow_extended_api",
17 | version=version,
18 | include_package_data=True,
19 | entry_points={
20 | "airflow.plugins": [
21 | "extended_api_plugin = extended_api.airflow_extended_api:ExtendedAPIPlugin"
22 | ]
23 | },
24 | zip_safe=False,
25 | long_description=long_description,
26 | long_description_content_type="text/markdown",
27 | url="https://github.com/caoergou/airflow-extended-api-plugin",
28 | author="Eric Cao",
29 | author_email="itsericsmail@gmail.com",
30 | description="Yet another Airflow plugin using CLI command as REST-ful API.",
31 | install_requires=["apache-airflow", "flask-swagger-ui"],
32 | license="Apache License, Version 2.0",
33 | python_requires=">=3.4",
34 | classifiers=[
35 | "Operating System :: OS Independent",
36 | "Programming Language :: Python",
37 | "Topic :: Software Development :: Libraries :: Python Modules",
38 | 'Intended Audience :: Developers',
39 | "Programming Language :: Python :: 3",
40 | "Programming Language :: Python :: 3.4",
41 | "Programming Language :: Python :: 3.5",
42 | "Programming Language :: Python :: 3.6",
43 | "Programming Language :: Python :: 3.7",
44 | "Programming Language :: Python :: 3.8",
45 | "Programming Language :: Python :: 3.9",
46 | "Programming Language :: Python :: 3.10",
47 | ]
48 | )
49 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Releases
2 |
3 | on:
4 | push:
5 | tags:
6 | - v*
7 |
8 | jobs:
9 |
10 | build:
11 | name: Build package
12 | runs-on: ubuntu-latest
13 |
14 | strategy:
15 | matrix:
16 | python-version: [ '3.7' ]
17 |
18 | steps:
19 | - name: Checkout code
20 | uses: actions/checkout@v2
21 |
22 | - name: Set up Python ${{ matrix.python-version }}
23 | uses: actions/setup-python@v2
24 | with:
25 | python-version: ${{ matrix.python-version }}
26 |
27 | - name: Cache pip
28 | uses: actions/cache@v2
29 | with:
30 | path: ~/.cache/pip
31 | key: ${{ runner.os }}-python-${{ matrix.python }}-pip-${{ hashFiles('**/requirements*.txt') }}-git-${{ github.sha }}
32 | restore-keys: |
33 | ${{ runner.os }}-python-${{ matrix.python }}-pip-${{ hashFiles('**/requirements*.txt') }}
34 | ${{ runner.os }}-python-${{ matrix.python }}-pip-
35 | ${{ runner.os }}-python
36 | ${{ runner.os }}-
37 |
38 | - name: Upgrade pip
39 | run: |
40 | python -m pip install --upgrade pip
41 | python -m pip install --upgrade setuptools wheel
42 |
43 | - name: Prepare environment
44 | run: pip freeze
45 |
46 | - name: Build package
47 | run: python setup.py bdist_wheel sdist
48 |
49 | - name: Check package
50 | run: twine check dist/*
51 |
52 | - name: Publish package
53 | env:
54 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
55 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
56 | run: |
57 | twine upload --skip-existing dist/*
58 |
59 | release:
60 | name: Release version
61 | runs-on: ubuntu-latest
62 | needs: [ build ]
63 |
64 | steps:
65 | - name: Create release
66 | id: create_release
67 | uses: actions/create-release@v1
68 | env:
69 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
70 | with:
71 | tag_name: ${{ github.ref }}
72 | release_name: ${{ github.ref }}
73 | draft: false
74 | prerelease: false
75 |
--------------------------------------------------------------------------------
/extended_api/service/plugin_service.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import threading
3 |
4 | from airflow.api_connexion.exceptions import BadRequest
5 | from flask import request, redirect, url_for, send_file
6 | from marshmallow import ValidationError
7 |
8 | from extended_api.constants import PLUGIN_STATIC_DIR_PATH
9 | from extended_api.model.models import commandExecutionResult, clearTaskSchema, runTaskSchema, backfillDAGRunSchema
10 | from extended_api.service.executor import execute_cli_command
11 |
12 | log = logging.getLogger(__name__)
13 |
14 |
15 | class APIService(object):
16 | default_view = "index"
17 |
18 | def _index(self):
19 | return redirect(url_for("swagger_ui.show"))
20 |
21 | def _openapi(self):
22 | from os import path as opath
23 | return send_file(opath.join(PLUGIN_STATIC_DIR_PATH, 'openapi.json'), mimetype='application/json')
24 |
25 | def _clear(self):
26 | log.info("Extended API clear called")
27 |
28 | body = request.get_json()
29 | try:
30 | command_list, username = clearTaskSchema.load(body)
31 | except ValidationError as err:
32 | raise BadRequest(detail=str(err.messages))
33 |
34 | output = execute_cli_command(command_list, username)
35 | return commandExecutionResult.load(output)
36 |
37 | def _backfill(self):
38 | log.info("Extended API backfill called")
39 |
40 | body = request.get_json()
41 | try:
42 | command_list, username = backfillDAGRunSchema.load(body)
43 | except ValidationError as err:
44 | raise BadRequest(detail=str(err.messages))
45 |
46 | # For backfilling: API will get 504 Gateway Time-out due to taking time to finish
47 | # output = execute_cli_command(command_list, username)
48 | # result = commandExecutionResult.load(output)
49 |
50 | # Instead of returning backfill result. Return immediately that operation continue in the background
51 | thread = threading.Thread(target=execute_cli_command, args=(command_list, username))
52 | thread.start()
53 |
54 | return {"message": "Backfill operation started"}, 202
55 |
56 | def _run(self):
57 | log.info("Extended API run called")
58 |
59 | body = request.get_json()
60 | try:
61 | command_list, username = runTaskSchema.load(body)
62 | except ValidationError as err:
63 | raise BadRequest(detail=str(err.messages))
64 |
65 | output = execute_cli_command(command_list, username)
66 | return commandExecutionResult.load(output)
67 |
--------------------------------------------------------------------------------
/extended_api/view/plugin_view.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | from airflow.api_connexion import security
4 | from airflow.www.app import csrf
5 | from flask_appbuilder import expose, BaseView
6 |
7 | from extended_api.constants import DOCS, ROUTE, DOCS_ITEM
8 | from extended_api.service.plugin_service import APIService
9 |
10 | log = logging.getLogger(__name__)
11 | __all__ = ["appbuilder_view"]
12 |
13 | try:
14 | from airflow.api_connexion.exceptions import BadRequest
15 |
16 | from airflow.www import auth
17 | from airflow.security import permissions
18 |
19 | PERMISSIONS = [
20 | (permissions.ACTION_CAN_READ, permissions.RESOURCE_AIRFLOW),
21 | ]
22 |
23 |
24 | class PluginExtendedAPIView(BaseView, APIService):
25 | # AppBuilder (Airflow >= 2.0)
26 |
27 | default_view = "index"
28 | route_base = ROUTE
29 |
30 | @expose("/index")
31 | def index(self):
32 | return self._index()
33 |
34 | @expose("/openapi")
35 | def openapi(self):
36 | return self._openapi()
37 |
38 | @expose("/clear", methods=["POST"])
39 | @csrf.exempt
40 | @security.requires_access(PERMISSIONS)
41 | def clear(self):
42 | return self._clear()
43 |
44 | @expose("/run", methods=["POST"])
45 | @csrf.exempt
46 | @security.requires_access(PERMISSIONS)
47 | def run(self):
48 | return self._run()
49 |
50 | @expose("/backfill", methods=["POST"])
51 | @csrf.exempt
52 | @security.requires_access(PERMISSIONS)
53 | def backfill(self):
54 | return self._backfill()
55 |
56 | except (ImportError, ModuleNotFoundError):
57 | # AppBuilder (Airflow >= 1.10 < 2.0)
58 | from airflow.www_rbac.decorators import has_dag_access
59 |
60 |
61 | class PluginExtendedAPIView(BaseView, APIService):
62 |
63 | default_view = "index"
64 | route_base = ROUTE
65 |
66 | @expose("/index")
67 | def index(self):
68 | return self._index()
69 |
70 | @expose("/openapi")
71 | def openapi(self):
72 | return self._openapi()
73 |
74 | @expose("/clear", methods=["POST"])
75 | @csrf.exempt
76 | @has_dag_access
77 | def clear(self):
78 | return self._clear()
79 |
80 | @expose("/run", methods=["POST"])
81 | @csrf.exempt
82 | @has_dag_access
83 | def run(self):
84 | return self._run()
85 |
86 | @expose("/backfill", methods=["POST"])
87 | @csrf.exempt
88 | @has_dag_access
89 | def backfill(self):
90 | return self._backfill()
91 |
92 | extended_api_view = PluginExtendedAPIView()
93 |
94 | appbuilder_view = {
95 | "category": DOCS,
96 | "name": DOCS_ITEM,
97 | "view": extended_api_view
98 | }
99 |
--------------------------------------------------------------------------------
/extended_api/model/models.py:
--------------------------------------------------------------------------------
1 | from typing import Tuple, List
2 |
3 | from flask import Response, jsonify
4 | from marshmallow import Schema, fields, post_load
5 |
6 |
7 | class CommandExecutionResult(Schema):
8 | """Command Execution Result Schema"""
9 | executed_command = fields.String()
10 | exit_code = fields.Integer()
11 | output_info = fields.String(default="")
12 | error_info = fields.String(default="")
13 |
14 | @post_load
15 | def gen_response(self, data, **kwargs) -> Response:
16 | output_str: str = data['output_info']
17 | data['output_info'] = output_str.split("\n")
18 |
19 | error_str: str = data['error_info']
20 | data['error_info'] = error_str.split("\n")
21 | return jsonify(data)
22 |
23 |
24 | class RunTaskRequestSchema(Schema):
25 | """Run Task Request Schema"""
26 | dag_name = fields.String(data_key="dagName", required=True)
27 | job_name = fields.String(data_key="jobName", required=True)
28 | start_date = fields.DateTime(data_key="startDate", required=True)
29 | username = fields.String(data_key="username", required=False, default="Extended API")
30 |
31 | @post_load
32 | def gen_command_list(self, data, **kwargs) -> Tuple[List[str], str]:
33 | start_date_str_UTC: str = data['start_date'].isoformat()
34 | command_list = ['airflow', 'tasks', 'run',
35 | data['dag_name'], data['job_name'], start_date_str_UTC]
36 |
37 | return command_list, data['username']
38 |
39 |
40 | class ClearTaskRequestSchema(Schema):
41 | """Clear Task Request Schema"""
42 | dag_name = fields.String(data_key="dagName", required=True)
43 | job_name = fields.String(data_key="jobName", required=True)
44 | start_date = fields.DateTime(data_key="startDate", required=True)
45 | end_date = fields.DateTime(data_key="endDate", required=True)
46 | downstream = fields.Boolean(data_key="downstream", default=False)
47 | username = fields.String(data_key="username", required=False, default="Extended API")
48 |
49 | @post_load
50 | def gen_command_list(self, data, **kwargs) -> Tuple[List[str], str]:
51 | start_date_str_UTC: str = data['start_date'].isoformat()
52 | end_date_str_UTC: str = data['end_date'].isoformat()
53 | command_list = ['airflow', 'tasks', 'clear', data['dag_name'],
54 | "-e", end_date_str_UTC,
55 | "-s", start_date_str_UTC,
56 | "-t", data['job_name'],
57 | "-y"]
58 |
59 | if data.get("downstream"):
60 | command_list.append("-d")
61 |
62 | return command_list, data['username']
63 |
64 |
65 | class BackfillDAGRunRequestSchema(Schema):
66 | """Backfill DAG Run Request Schema"""
67 | dag_name = fields.String(data_key="dagName", required=True)
68 | start_date = fields.DateTime(data_key="startDate", required=True)
69 | end_date = fields.DateTime(data_key="endDate", required=True)
70 | job_name = fields.String(data_key="jobName", required=True)
71 |
72 | pool = fields.String(data_key="pool", required=False)
73 | rerun_failed_tasks = fields.Boolean(data_key="rerunFailedTasks", required=False)
74 | ignore_dependencies = fields.Boolean(data_key="ignoreDependencies", required=False)
75 | username = fields.String(data_key="username", required=False, default="Extended API")
76 | continue_on_failures = fields.Boolean(data_key="continueOnFailures", required=False)
77 | dry_run = fields.Boolean(data_key="dryRun", required=False)
78 | run_backwards = fields.Boolean(data_key="runBackwards", required=False)
79 |
80 | @post_load
81 | def gen_command_list(self, data, **kwargs) -> Tuple[List[str], str]:
82 | start_date_str_UTC: str = data['start_date'].isoformat()
83 | end_date_str_UTC: str = data['end_date'].isoformat()
84 | command_list = ['airflow', 'dags', 'backfill', data['dag_name'],
85 | "-s", start_date_str_UTC,
86 | "-e", end_date_str_UTC,
87 | "-t", data['job_name'],
88 | "-y"]
89 |
90 | if data.get("pool"):
91 | command_list += ['--pool', data['pool']]
92 |
93 | if data.get("rerun_failed_tasks"):
94 | command_list.append('--rerun-failed-tasks')
95 |
96 | if data.get("ignore_dependencies"):
97 | command_list.append('--ignore-dependencies')
98 |
99 | if data.get("continue_on_failures"):
100 | command_list += ['--continue-on-failures']
101 |
102 | if data.get("dry_run"):
103 | command_list += ['--dry-run']
104 |
105 | if data.get("run_backwards"):
106 | command_list.append('--run-backwards')
107 |
108 | return command_list, data['username']
109 |
110 |
111 | commandExecutionResult = CommandExecutionResult()
112 | runTaskSchema = RunTaskRequestSchema()
113 | clearTaskSchema = ClearTaskRequestSchema()
114 | backfillDAGRunSchema = BackfillDAGRunRequestSchema()
115 |
--------------------------------------------------------------------------------
/README_CN.md:
--------------------------------------------------------------------------------
1 | [English Document](https://github.com/caoergou/airflow-extended-api-plugin/blob/main/README.md)
2 |
3 | # Airflow 拓展 API 插件
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | 可将 airflow 的命令行包装成 REST-ful 风格 API 的插件,以扩展 Airflow 官方 API 的能力。
18 |
19 | 该插件可用于 Airflow 2.0 以上版本,且易于扩展,你可以根据需要修改代码,将任意 CLI 命令封装成 API。
20 |
21 | ## 当前支持的命令
22 |
23 | 当前支持使用以下命令:
24 |
25 | - `airflow dags backfill`
26 | - `airflow tasks run`
27 | - `airflow tasks clear`
28 |
29 | ## 安装插件
30 |
31 | 1. 通过 Pip 安装
32 |
33 | ```bash
34 | pip install airflow-extended-api
35 | ```
36 |
37 | 2. 重启 Airflow WebServer
38 |
39 | 3. 打开 Airflow 界面中的 `Docs - Extended API OpenAPI` 或 `http://localhost:8080/` 来查看 API 细节。
40 |
41 | 
42 |
43 | ## 使用 API
44 |
45 | ### 一般调用示例
46 |
47 | #### curl 请求:
48 |
49 | ```bash
50 | curl -X POST --user "airflow:airflow" https://localhost:8080/api/extended/clear -H "Content-Type: application/json" -d '{"dagName": "string","downstream": true,"endDate": "2019-08-24T14:15:22Z","jobName": "string","startDate": "2019-08-24T14:15:22Z","username": "Extended API"}'
51 | ```
52 |
53 | #### 返回结果格式:
54 |
55 | ```json
56 | {
57 | "executed_command": "string",
58 | "exit_code": 0,
59 | "output_info": [
60 | "string"
61 | ],
62 | "error_info": [
63 | "string"
64 | ]
65 | }
66 | ```
67 |
68 | ### 身份验证
69 |
70 | #### 不带身份信息的 curl 请求
71 |
72 | 请以`--user "{username}:{password}"`的样式提供 airflow 账户信息,否则将鉴权失败。
73 |
74 | ```bash
75 | curl -X POST http://127.0.0.1:8080/api/extended/clear -H "Content-Type: application/json" -d '{"dagName": "string","downstream": true,"endDate": "2019-08-24T14:15:22Z","jobName": "string","startDate": "2019-08-24T14:15:22Z","username": "Extended API"}'
76 | ```
77 |
78 | ### 返回结果
79 |
80 | ```json
81 | {
82 | "detail": null,
83 | "status": 401,
84 | "title": "Unauthorized",
85 | "type": "https://airflow.apache.org/docs/apache-airflow/2.2.5/stable-rest-api-ref.html#section/Errors/Unauthenticated"
86 | }
87 | ```
88 |
89 | ### 错误的命令行
90 |
91 | #### curl 请求
92 |
93 | ```bash
94 | curl -X POST --user "airflow:airflow" http://127.0.0.1:8080/api/extended/clear -H "Content-Type: application/json" -d '{"dagName": "string","downstream": true,"endDate": "2019-08-24T14:15:22Z","jobName": "string","startDate": "2019-08-24T14:15:22Z","username": "Extended API"}'
95 | ```
96 |
97 | ### 返回结果
98 |
99 | ```json
100 | {
101 | "error_info": [
102 | "Traceback (most recent call last):",
103 | " File \"/home/airflow/.local/bin/airflow\", line 8, in ",
104 | " sys.exit(main())",
105 | " File \"/home/airflow/.local/lib/python3.7/site-packages/airflow/__main__.py\", line 48, in main",
106 | " args.func(args)",
107 | " File \"/home/airflow/.local/lib/python3.7/site-packages/airflow/cli/cli_parser.py\", line 48, in command",
108 | " return func(*args, **kwargs)",
109 | " File \"/home/airflow/.local/lib/python3.7/site-packages/airflow/utils/cli.py\", line 92, in wrapper",
110 | " return f(*args, **kwargs)",
111 | " File \"/home/airflow/.local/lib/python3.7/site-packages/airflow/cli/commands/task_command.py\", line 506, in task_clear",
112 | " dags = get_dags(args.subdir, args.dag_id, use_regex=args.dag_regex)",
113 | " File \"/home/airflow/.local/lib/python3.7/site-packages/airflow/utils/cli.py\", line 203, in get_dags",
114 | " return [get_dag(subdir, dag_id)]",
115 | " File \"/home/airflow/.local/lib/python3.7/site-packages/airflow/utils/cli.py\", line 193, in get_dag",
116 | " f\"Dag {dag_id!r} could not be found; either it does not exist or it failed to parse.\"",
117 | "airflow.exceptions.AirflowException: Dag 'string' could not be found; either it does not exist or it failed to parse.",
118 | ""
119 | ],
120 | "executed_command": "airflow tasks clear string -e 2019-08-24T14:15:22+00:00 -s 2019-08-24T14:15:22+00:00 -t string -y -d",
121 | "exit_code": 1,
122 | "output_info": [
123 | "[\u001b[34m2022-04-22 10:05:50,538\u001b[0m] {\u001b[34mdagbag.py:\u001b[0m500} INFO\u001b[0m - Filling up the DagBag from /opt/airflow/dags\u001b[0m",
124 | ""
125 | ]
126 | }
127 | ```
128 |
129 | ## 项目计划
130 |
131 | - [ ] 支持 `backfill` 命令
132 | - [ ] support custom configuration
133 |
134 | ## 相关链接
135 |
136 | - [Airflow 配置文档](https://airflow.apache.org/docs/stable/configurations-ref.html)
137 | - [Airflow 命令行工具](https://airflow.apache.org/docs/apache-airflow/stable/cli-and-env-variables-ref.html)
138 | - 开发过程中参考了以下项目,在此表示感谢
139 | - [andreax79/airflow-code-editor](https://github.com/andreax79/airflow-code-editor)
140 | - [airflow-plugins/airflow_api_plugin](https://github.com/airflow-plugins/airflow_api_plugin)
141 | - 联系邮箱 Eric Cao `itsericsmail@gmail.com`
142 |
143 |
144 |
145 |
146 |
147 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [中文版文档](https://github.com/caoergou/airflow-extended-api-plugin/blob/main/README_CN.md)
2 |
3 | # Airflow Extended API Plugin
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | Airflow Extended API, which
21 | export [airflow CLI command](https://airflow.apache.org/docs/apache-airflow/2.0.2/cli-and-env-variables-ref.html) as
22 | REST-ful API to extend the ability of airflow official API.
23 |
24 | This plugin is available for airflow 2.x Version and extensible, as you can easily define your own API to execute any
25 | Airflow CLI command so that it fits your demand.
26 |
27 | ## Current Supported Commands
28 |
29 | The following commands are supported now, and more is coming.
30 |
31 | - `airflow dags backfill`
32 | - `airflow tasks run`
33 | - `airflow tasks clear`
34 |
35 | ## Plugin Install
36 |
37 | 1. Install the plugin via `pip`
38 |
39 | ```bash
40 | pip install airflow-extended-api
41 | ```
42 |
43 | 2. Restart the Airflow Web Server
44 |
45 | 3. Open Airflow UI in `Docs - Extended API OpenAPI` or `http://localhost:8080/` to view extended API details in swagger
46 | UI.
47 | 
48 |
49 | ## Usage
50 |
51 | ### Examples
52 |
53 | #### curl request example:
54 |
55 | ```bash
56 | curl -X POST --user "airflow:airflow" https://localhost:8080/api/extended/clear -H "Content-Type: application/json" -d '{"dagName": "string","downstream": true,"endDate": "2019-08-24T14:15:22Z","jobName": "string","startDate": "2019-08-24T14:15:22Z","username": "Extended API"}'
57 | ```
58 |
59 | #### Response Schema:
60 |
61 | ```json
62 | {
63 | "executed_command": "string",
64 | "exit_code": 0,
65 | "output_info": [
66 | "string"
67 | ],
68 | "error_info": [
69 | "string"
70 | ]
71 | }
72 | ```
73 |
74 | #### curl without Credentials data
75 |
76 | Note that you will need to pass credentials' data in `--user "{username}:{password}"` format, or you will get an
77 | Unauthorized error.
78 |
79 | ```bash
80 | curl -X POST http://127.0.0.1:8080/api/extended/clear -H "Content-Type: application/json" -d '{"dagName": "string","downstream": true,"endDate": "2019-08-24T14:15:22Z","jobName": "string","startDate": "2019-08-24T14:15:22Z","username": "Extended API"}'
81 | ```
82 |
83 | ### response
84 |
85 | ```json
86 | {
87 | "detail": null,
88 | "status": 401,
89 | "title": "Unauthorized",
90 | "type": "https://airflow.apache.org/docs/apache-airflow/2.2.5/stable-rest-api-ref.html#section/Errors/Unauthenticated"
91 | }
92 | ```
93 |
94 | #### curl with wrong CLI Command
95 |
96 | ```bash
97 | curl -X POST --user "airflow:airflow" http://127.0.0.1:8080/api/extended/clear -H "Content-Type: application/json" -d '{"dagName": "string","downstream": true,"endDate": "2019-08-24T14:15:22Z","jobName": "string","startDate": "2019-08-24T14:15:22Z","username": "Extended API"}'
98 | ```
99 |
100 | ### response
101 |
102 | ```json
103 | {
104 | "error_info": [
105 | "Traceback (most recent call last):",
106 | " File \"/home/airflow/.local/bin/airflow\", line 8, in ",
107 | " sys.exit(main())",
108 | " File \"/home/airflow/.local/lib/python3.7/site-packages/airflow/__main__.py\", line 48, in main",
109 | " args.func(args)",
110 | " File \"/home/airflow/.local/lib/python3.7/site-packages/airflow/cli/cli_parser.py\", line 48, in command",
111 | " return func(*args, **kwargs)",
112 | " File \"/home/airflow/.local/lib/python3.7/site-packages/airflow/utils/cli.py\", line 92, in wrapper",
113 | " return f(*args, **kwargs)",
114 | " File \"/home/airflow/.local/lib/python3.7/site-packages/airflow/cli/commands/task_command.py\", line 506, in task_clear",
115 | " dags = get_dags(args.subdir, args.dag_id, use_regex=args.dag_regex)",
116 | " File \"/home/airflow/.local/lib/python3.7/site-packages/airflow/utils/cli.py\", line 203, in get_dags",
117 | " return [get_dag(subdir, dag_id)]",
118 | " File \"/home/airflow/.local/lib/python3.7/site-packages/airflow/utils/cli.py\", line 193, in get_dag",
119 | " f\"Dag {dag_id!r} could not be found; either it does not exist or it failed to parse.\"",
120 | "airflow.exceptions.AirflowException: Dag 'string' could not be found; either it does not exist or it failed to parse.",
121 | ""
122 | ],
123 | "executed_command": "airflow tasks clear string -e 2019-08-24T14:15:22+00:00 -s 2019-08-24T14:15:22+00:00 -t string -y -d",
124 | "exit_code": 1,
125 | "output_info": [
126 | "[\u001b[34m2022-04-22 10:05:50,538\u001b[0m] {\u001b[34mdagbag.py:\u001b[0m500} INFO\u001b[0m - Filling up the DagBag from /opt/airflow/dags\u001b[0m",
127 | ""
128 | ]
129 | }
130 | ```
131 |
132 | ## Project Plan
133 |
134 | - [ ] support custom configuration
135 |
136 | ## Links and References
137 |
138 | - [Airflow configuration documentation](https://airflow.apache.org/docs/stable/configurations-ref.html)
139 | - [Airflow CLI command documentation](https://airflow.apache.org/docs/apache-airflow/stable/cli-and-env-variables-ref.html)
140 | - This project was inspired by the following projects:
141 | - [andreax79/airflow-code-editor](https://github.com/andreax79/airflow-code-editor)
142 | - [airflow-plugins/airflow_api_plugin](https://github.com/airflow-plugins/airflow_api_plugin)
143 | - Contact email: Eric Cao `itsericsmail@gmail.com`
144 |
145 |
146 |
147 |
148 |
149 |
--------------------------------------------------------------------------------
/extended_api/static/openapi.json:
--------------------------------------------------------------------------------
1 | {
2 | "openapi": "3.0.0",
3 | "info": {
4 | "title": "Airflow Extended API Doc",
5 | "description": "Airflow Extended API, which export airflow CLI command as REST-ful API\nto extend the ability of airflow official API.\n",
6 | "contact": {
7 | "name": "Eric Cao",
8 | "email": "itsericsmail@gmail.com"
9 | },
10 | "version": "1.1.3",
11 | "license": {
12 | "name": "Apache 2.0",
13 | "url": "https://www.apache.org/licenses/LICENSE-2.0.html"
14 | }
15 | },
16 | "servers": [
17 | {
18 | "url": "https://airflow-webserver-domin",
19 | "description": "SwaggerHub API Auto Mocking"
20 | }
21 | ],
22 | "tags": [
23 | {
24 | "name": "Extended API"
25 | }
26 | ],
27 | "paths": {
28 | "/api/extended/clear": {
29 | "post": {
30 | "summary": "Clear a task for rerun.",
31 | "requestBody": {
32 | "description": "Args for Airflow CLI command [airflow tasks clear].",
33 | "content": {
34 | "application/json": {
35 | "schema": {
36 | "$ref": "#/components/schemas/clearTaskRequest"
37 | }
38 | }
39 | },
40 | "required": true
41 | },
42 | "responses": {
43 | "200": {
44 | "description": "Command execution result.",
45 | "content": {
46 | "application/json": {
47 | "schema": {
48 | "$ref": "#/components/schemas/commandResult"
49 | }
50 | }
51 | }
52 | },
53 | "405": {
54 | "description": "Invalid input"
55 | }
56 | },
57 | "tags": [
58 | "Extended API"
59 | ]
60 | }
61 | },
62 | "/api/extended/run": {
63 | "post": {
64 | "summary": "Run a task right now.",
65 | "requestBody": {
66 | "description": "Args for Airflow CLI command [airflow tasks run].",
67 | "content": {
68 | "application/json": {
69 | "schema": {
70 | "$ref": "#/components/schemas/runTaskRequest"
71 | }
72 | }
73 | },
74 | "required": true
75 | },
76 | "responses": {
77 | "200": {
78 | "description": "Command execution result.",
79 | "content": {
80 | "application/json": {
81 | "schema": {
82 | "$ref": "#/components/schemas/commandResult"
83 | }
84 | }
85 | }
86 | },
87 | "405": {
88 | "description": "Invalid input"
89 | }
90 | },
91 | "tags": [
92 | "Extended API"
93 | ]
94 | }
95 | },
96 | "/api/extended/backfill": {
97 | "post": {
98 | "summary": "Backfill a task right now.",
99 | "requestBody": {
100 | "description": "Args for Airflow CLI command [airflow dags backfill].",
101 | "content": {
102 | "application/json": {
103 | "schema": {
104 | "$ref": "#/components/schemas/backfillDAGRunRequest"
105 | }
106 | }
107 | },
108 | "required": true
109 | },
110 | "responses": {
111 | "202": {
112 | "description": "Backfill operation started."
113 | },
114 | "405": {
115 | "description": "Invalid input"
116 | }
117 | },
118 | "tags": [
119 | "Extended API"
120 | ]
121 | }
122 | }
123 | },
124 | "components": {
125 | "schemas": {
126 | "clearTaskRequest": {
127 | "required": [
128 | "dagName",
129 | "endDate",
130 | "jobName",
131 | "startDate"
132 | ],
133 | "type": "object",
134 | "properties": {
135 | "dagName": {
136 | "type": "string",
137 | "description": "Name of DAG to clear"
138 | },
139 | "downstream": {
140 | "type": "boolean",
141 | "description": "Whether to recursively clear downstream tasks"
142 | },
143 | "endDate": {
144 | "type": "string",
145 | "format": "date-time",
146 | "description": "End of the time range for task instance to clear"
147 | },
148 | "jobName": {
149 | "type": "string",
150 | "description": "Regex of operator to clear"
151 | },
152 | "startDate": {
153 | "type": "string",
154 | "format": "date-time",
155 | "description": "Start of the time range for task instance to clear"
156 | },
157 | "username": {
158 | "type": "string",
159 | "default": "Extended API",
160 | "description": "Username who call this API"
161 | }
162 | }
163 | },
164 | "backfillDAGRunRequest": {
165 | "required": [
166 | "dagName",
167 | "jobName",
168 | "startDate",
169 | "endDate"
170 | ],
171 | "type": "object",
172 | "properties": {
173 | "dagName": {
174 | "type": "string",
175 | "description": "Name of DAG to clear"
176 | },
177 | "startDate": {
178 | "type": "string",
179 | "format": "date-time",
180 | "description": "Start of the time range for task instance to clear"
181 | },
182 | "endDate": {
183 | "type": "string",
184 | "format": "date-time",
185 | "description": "End of the time range for task instance to clear"
186 | },
187 | "jobName": {
188 | "type": "string",
189 | "description": "Regex of operator to clear"
190 | },
191 | "pool": {
192 | "type": "string",
193 | "description": "Resource pool to use"
194 | },
195 | "rerunFailedTasks": {
196 | "type": "boolean",
197 | "description": "If set, the backfill will auto-rerun all the failed tasks for the backfill date range instead of throwing exceptions"
198 | },
199 | "ignoreDependencies": {
200 | "type": "boolean",
201 | "description": "Skip upstream tasks, run only the tasks matching the regexp. Only works in conjunction with task_regex"
202 | },
203 | "username": {
204 | "type": "string",
205 | "default": "Extended API",
206 | "description": "Username who call this API"
207 | },
208 | "continueOnFailures": {
209 | "type": "boolean",
210 | "description": "If set, the backfill will keep going even if some of the tasks failed"
211 | },
212 | "dryRun": {
213 | "type": "boolean",
214 | "description": "If set, perform a dry run for each task. Only renders Template Fields for each task, nothing else"
215 | },
216 | "runBackwards": {
217 | "type": "boolean",
218 | "description": "If set, the backfill will run tasks from the most recent day first. if there are tasks that depend_on_past this option will throw an exception"
219 | }
220 | }
221 | },
222 | "runTaskRequest": {
223 | "required": [
224 | "dagName",
225 | "jobName",
226 | "startDate"
227 | ],
228 | "type": "object",
229 | "properties": {
230 | "dagName": {
231 | "type": "string",
232 | "description": "Name of DAG to run"
233 | },
234 | "jobName": {
235 | "type": "string",
236 | "description": "Name of operator to run"
237 | },
238 | "startDate": {
239 | "type": "string",
240 | "format": "date-time",
241 | "description": "Start of the time range for task instance to clear"
242 | },
243 | "username": {
244 | "type": "string",
245 | "default": "Extended API",
246 | "description": "Username who call this API"
247 | }
248 | }
249 | },
250 | "commandResult": {
251 | "type": "object",
252 | "properties": {
253 | "executed_command": {
254 | "type": "string",
255 | "description": "executed airflow CLI command"
256 | },
257 | "exit_code": {
258 | "type": "integer",
259 | "format": "int32",
260 | "description": "command exit code"
261 | },
262 | "output_info": {
263 | "type": "array",
264 | "items": {
265 | "type": "string"
266 | },
267 | "description": "command execute output as list of string"
268 | },
269 | "error_info": {
270 | "type": "array",
271 | "items": {
272 | "type": "string"
273 | },
274 | "description": "command execute error as list of string"
275 | }
276 | }
277 | }
278 | }
279 | }
280 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "[]"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright [yyyy] [name of copyright owner]
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------