├── .github └── workflows │ ├── publish.yml │ └── unittest.yml ├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── dev.sh ├── frontend ├── .eslintrc.js ├── .gitignore ├── babel.config.js ├── package-lock.json ├── package.json ├── src │ ├── App.vue │ ├── apis │ │ ├── apiList.js │ │ ├── banner.js │ │ ├── index.js │ │ └── info.js │ ├── components │ │ ├── CodeEditor.vue │ │ ├── apiList.vue │ │ ├── banner.vue │ │ ├── coverage.vue │ │ ├── flowDetail.vue │ │ └── info.vue │ ├── main.js │ ├── store │ │ ├── apiList.js │ │ ├── index.js │ │ └── info.js │ └── utils │ │ └── jsonpath.js └── vue.config.js ├── images ├── introduce.png └── main.png ├── lyrebird_api_coverage ├── __init__.py ├── api.py ├── client │ ├── __init__.py │ ├── config.py │ ├── context.py │ ├── event_subscibe.py │ ├── filter_url.py │ ├── format_url.py │ ├── jsonscheme.py │ ├── load_base.py │ ├── merge_algorithm.py │ ├── report.py │ └── url_compare.py ├── default_conf │ ├── base.json │ ├── conf.json │ └── filter_config.json ├── handlers │ ├── __init__.py │ ├── base_source_handler.py │ ├── filter_handler.py │ ├── import_file_handler.py │ └── result_handler.py ├── interceptor.py ├── manifest.py └── version.py ├── requirements.txt ├── setup.py └── tests └── test_verify_parameter_name.py /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Publish to pypi 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | publish: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Set up Node 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: '10' 20 | - name: Install npm Dependencies 21 | run: | 22 | cd frontend 23 | npm install 24 | npm run build 25 | cd .. 26 | - name: Set up Python 27 | uses: actions/setup-python@v1 28 | with: 29 | python-version: '3.7' 30 | - name: Install python dependencies 31 | run: | 32 | python -m pip install --upgrade pip 33 | pip install setuptools wheel twine 34 | - name: Build and publish 35 | env: 36 | TWINE_USERNAME: __token__ 37 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 38 | run: | 39 | python setup.py sdist 40 | twine upload dist/* 41 | -------------------------------------------------------------------------------- /.github/workflows/unittest.yml: -------------------------------------------------------------------------------- 1 | name: Unit Test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | unittest: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v2 11 | - name: Set up Python 12 | uses: actions/setup-python@v1 13 | with: 14 | python-version: '3.7' 15 | - name: Install python dependencies 16 | run: | 17 | python -m pip install --upgrade pip 18 | pip install -r requirements.txt 19 | pip install . 20 | - name: Unit Test 21 | run: | 22 | cd tests 23 | python -m pytest . 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | # Created by .ignore support plugin (hsz.mobi) 3 | ### macOS template 4 | *.DS_Store 5 | .AppleDouble 6 | .LSOverride 7 | 8 | # Icon must end with two \r 9 | Icon 10 | 11 | 12 | # Thumbnails 13 | ._* 14 | 15 | # Files that might appear in the root of a volume 16 | .DocumentRevisions-V100 17 | .fseventsd 18 | .Spotlight-V100 19 | .TemporaryItems 20 | .Trashes 21 | .VolumeIcon.icns 22 | .com.apple.timemachine.donotpresent 23 | 24 | # Directories potentially created on remote AFP share 25 | .AppleDB 26 | .AppleDesktop 27 | Network Trash Folder 28 | Temporary Items 29 | .apdisk 30 | ### Python template 31 | # Byte-compiled / optimized / DLL files 32 | __pycache__/ 33 | *.py[cod] 34 | *$py.class 35 | 36 | # C extensions 37 | *.so 38 | 39 | # Distribution / packaging 40 | .Python 41 | env/ 42 | build/ 43 | develop-eggs/ 44 | dist/ 45 | downloads/ 46 | eggs/ 47 | .eggs/ 48 | lib/ 49 | lib64/ 50 | parts/ 51 | sdist/ 52 | var/ 53 | wheels/ 54 | *.egg-info/ 55 | .installed.cfg 56 | *.egg 57 | 58 | # PyInstaller 59 | # Usually these files are written by a python script from a template 60 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 61 | *.manifest 62 | *.spec 63 | 64 | # Installer logs 65 | pip-log.txt 66 | pip-delete-this-directory.txt 67 | 68 | # Unit test / coverage reports 69 | htmlcov/ 70 | .tox/ 71 | .coverage 72 | .coverage.* 73 | .cache 74 | nosetests.xml 75 | coverage.xml 76 | *,cover 77 | .hypothesis/ 78 | 79 | # Translations 80 | *.mo 81 | *.pot 82 | 83 | # Django stuff: 84 | *.log 85 | local_settings.py 86 | 87 | # Flask stuff: 88 | instance/ 89 | .webassets-cache 90 | 91 | # Scrapy stuff: 92 | .scrapy 93 | 94 | # Sphinx documentation 95 | docs/_build/ 96 | 97 | # PyBuilder 98 | target/ 99 | 100 | # Jupyter Notebook 101 | .ipynb_checkpoints 102 | 103 | # pyenv 104 | .python-version 105 | 106 | # celery beat schedule file 107 | celerybeat-schedule 108 | 109 | # SageMath parsed files 110 | *.sage.py 111 | 112 | # dotenv 113 | .env 114 | 115 | # virtualenv 116 | .venv 117 | venv/ 118 | ENV/ 119 | 120 | # Spyder project settings 121 | .spyderproject 122 | 123 | # Rope project settings 124 | .ropeproject 125 | 126 | #IDEA 127 | .idea/ 128 | 129 | #mock 130 | mock/record/ 131 | data/ 132 | tmp/ 133 | __pycache__ / 134 | 135 | .vscode/ 136 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018-present, 美团点评 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | graft lyrebird_api_coverage 2 | recursive-exclude * *.pyc *.pyo *.swo *.swp *.map *.DS_Store -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lyrebird Plugin API-Coverage 2 | 3 | [](https://travis-ci.org/meituan/lyrebird-api-coverage) 4 | 5 | ## 简介 6 | * API-Coverage是基于[Lyrebird](https://github.com/meituan/lyrebird)的插件,为客户端提供API维度测试覆盖评估方法。 7 | * 客户端的操作可以实时反应在前端页面上,主要有API覆盖率统计、不同优先级的覆盖率展示等。可以参考该数据,判断测试是否已覆盖目标功能。 8 | * API覆盖率的计算公式:覆盖率 = 已访问API/基准API 9 | * 已访问API:被测应用已发出的请求(只记录当前业务内API) 10 | * 基准API:当前业务所有API 11 | 12 | 13 | 14 | 15 | ## 环境要求 16 | 17 | * macOS 18 | 19 | * Python3.7及以上 20 | 21 | 22 | ## 安装 23 | 24 | ``` bash 25 | pip3 install lyrebird-api-coverage 26 | ``` 27 | 28 | 29 | ## 启动 30 | ```bash 31 | lyrebird 32 | ``` 33 | 34 | ## 界面说明 35 | 36 | 37 | 38 | 如图所示,不同区域的介绍: 39 | 40 | 1. 工具栏 41 | 1. Import Base - 导入需要统计的基准API列表(文件格式见附录[Base数据格式](#Base数据格式)) 42 | 2. Resume Test - 导入统计结果并继续统计 43 | 3. Save Result - 导出统计结果到“~/.lyrebird/plugin/lyrebird_api_coverage/data/” 44 | 4. Clear Test - 清空当前的统计结果 45 | 5. Filtering Rules - 过滤规则设置(配置格式见附录[过滤配置数据格式](#过滤配置数据格式)) 46 | 47 | 2. 覆盖率信息 48 | 49 | 1. 展示覆盖率信息,总体覆盖率信息,分优先级覆盖率信息 50 | 51 | 3. 基准API信息 52 | 1. 展示当前生效的基准API信息 53 | 54 | 4. 覆盖率详情模块 55 | 56 | 1. Priority:API的优先级 57 | 2. API: URL信息 58 | 3. Description:API的描述信息 59 | 4. Count:API的请求次数 60 | 5. Status:API的状态,包括 已测试,未测试,不在base中的API 61 | 6. Detail:查看请求详情,点击表格最后一列的详情中的Detail,就可以展示最近一次的请求的详情 62 | 63 | ## 使用流程 64 | 65 | 1. 准备Base数据,Base数据格式[见附录](#Base数据格式) 66 | 2. 点击工具栏中的“Import Base”按钮进行导入Base文件 67 | 3. 操作过程中观测页面的覆盖率等信息展示 68 | 69 | 70 | ## 开发者指南 71 | 72 | ```bash 73 | # clone 代码 74 | git clone https://github.com/meituan/lyrebird-api-coverage.git 75 | 76 | # 进入工程目录 77 | cd lyrebird-api-coverage 78 | 79 | # 创建虚拟环境 80 | python3 -m venv venv 81 | 82 | # 安装依赖 83 | source venv/bin/activate 84 | pip3 install -r requirements.txt 85 | 86 | # 使用IDE打开工程(推荐Pycharm或vscode) 87 | 88 | # 在IDE中执行debug.py即可开始调试 89 | ``` 90 | 91 | 92 | ## 附录 93 | ### Base数据格式 94 | 95 | ```json 96 | { 97 | "business": "app_channel", 98 | "version_code": 1, 99 | "version_name": "1.0", 100 | "api_list": [ 101 | { 102 | "desc": "A接口", 103 | "priority": 3, 104 | "url": "meituan.com/test/a" 105 | }, 106 | { 107 | "desc": "B接口", 108 | "priority": 2, 109 | "url": "meituan.com/test/b?paramKey=val" 110 | }, 111 | { 112 | "desc": "C接口", 113 | "priority": 2, 114 | "url": "meituan.com/test/c/{num}" 115 | }, 116 | { 117 | "desc": "D接口", 118 | "priority": 1, 119 | "url": "meituan.com/test/d?sourceType=1" 120 | } 121 | ] 122 | } 123 | ``` 124 | - 支持两种API,Path 和 Path + query,即不带参数的配置和带参数的配置 125 | - 在配置API时,如果path中带有参数,如 a.b.com/v1/test/{num},需要用'{}'括起,在覆盖率计算中用来判断是同一API 126 | - 配置参数的情况下,字段名的大小写敏感 127 | 128 | ### 过滤配置数据格式 129 | - demo 130 | 131 | ```json 132 | { 133 | "exclude": { 134 | "host": [ 135 | "a.meituan.com", 136 | "b.baidu.com" 137 | ], 138 | "regular": [ 139 | ".webp", 140 | ".gif", 141 | ".jpg", 142 | ".png" 143 | ] 144 | } 145 | } 146 | ``` 147 | - 支持两种筛除规则,以host为维度,以包含字符串为维度 148 | - 如果不想关注某些host下的请求,可以按照上述筛选配置文件的数据格式配置 host字段下的规则 149 | - 如果不想关注某些包含指定字符串的请求(如:.webp),可以按照上述筛选配置文件的数据格式配置 regular字段下的规则 150 | 151 | - 字段说明 152 | - exclude:不关注的配置项 153 | - host:不关注的host 154 | - regular:不关注的字符串(URL只要包含指定的字符串都会筛选掉) 155 | 156 | -------------------------------------------------------------------------------- /dev.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | echo "***************************" 4 | echo " Lyrebird-API-Coverage setup start " 5 | echo "***************************" 6 | 7 | # 如果已经有venv目录,删除此目录 8 | if [ -e "./venv/" ]; then 9 | rm -rf ./venv/ 10 | fi 11 | 12 | mkdir venv 13 | python3 -m venv ./venv 14 | 15 | # 有些设备上虚拟环境中没有pip,需要通过easy_install安装 16 | if [ ! -e "./venv/bin/pip" ] ;then 17 | echo "pip no exist, install pip with easy_install" 18 | ./venv/bin/easy_install pip 19 | fi 20 | 21 | source ./venv/bin/activate 22 | pip3 install -r ./requirements.txt 23 | 24 | # 如果没有data目录,创建此目录 25 | if [ ! -e "./data/" ]; then 26 | mkdir ./data 27 | fi 28 | 29 | echo "***************************" 30 | echo " Lyrebird-API-Coverage setup finish " 31 | echo "***************************" 32 | -------------------------------------------------------------------------------- /frontend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es6: true, 5 | }, 6 | extends: ['plugin:vue/essential', 'airbnb-base'], 7 | globals: { 8 | Atomics: 'readonly', 9 | SharedArrayBuffer: 'readonly', 10 | }, 11 | parserOptions: { 12 | ecmaVersion: 2018, 13 | sourceType: 'module', 14 | }, 15 | plugins: ['vue'], 16 | rules: { 17 | quotes: [1, 'single'], //引号类型 18 | 'quote-props': [2, 'as-needed'], // 双引号自动变单引号 19 | semi: [2, 'never'], //分号 20 | 'comma-dangle': [1, 'always-multiline'], // 对象或数组多行写法时,最后一个值加逗号 21 | 'space-after-keywords': [0, 'always'], //关键字后面是否要空一格 22 | 'comma-dangle': [2, 'always-multiline'], // 逗号 23 | 'no-param-reassign': 0, //禁止给参数重新赋值 24 | 'no-bitwise': 0, //禁止使用按位运算符 25 | 'no-restricted-syntax': 0, 26 | 'vue/no-side-effects-in-computed-properties': 0, 27 | 'import/no-unresolved': 0, 28 | 'no-prototype-builtins': 0, 29 | 'consistent-return': 0, 30 | }, 31 | }; 32 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Editor directories and files 15 | .idea 16 | .vscode 17 | *.suo 18 | *.ntvs* 19 | *.njsproj 20 | *.sln 21 | *.sw? 22 | -------------------------------------------------------------------------------- /frontend/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset', 4 | ], 5 | } 6 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build": "vue-cli-service build", 8 | "lint": "eslint --fix --ext .js,.vue,.js ./", 9 | "lint:create": "eslint --init" 10 | }, 11 | "dependencies": { 12 | "axios": "^0.27.2", 13 | "core-js": "^3.3.2", 14 | "monaco-editor": "^0.20.0", 15 | "socket.io-client": "^3.1.3", 16 | "view-design": "^4.0.2", 17 | "vue": "^2.6.10", 18 | "vue-json-views": "^1.1.0", 19 | "vuex": "^3.0.1" 20 | }, 21 | "devDependencies": { 22 | "@vue/cli-plugin-babel": "^4.0.0", 23 | "@vue/cli-plugin-vuex": "^4.0.0", 24 | "@vue/cli-service": "^4.0.0", 25 | "eslint": "^6.8.0", 26 | "eslint-config-airbnb-base": "^14.0.0", 27 | "eslint-plugin-import": "^2.20.1", 28 | "eslint-plugin-vue": "^6.2.1", 29 | "vue-template-compiler": "^2.6.10" 30 | }, 31 | "postcss": { 32 | "plugins": { 33 | "autoprefixer": {} 34 | } 35 | }, 36 | "browserslist": [ 37 | "> 1%", 38 | "last 2 versions" 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /frontend/src/App.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 65 | 66 | 71 | -------------------------------------------------------------------------------- /frontend/src/apis/apiList.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | // getFlowDetail 4 | const getFlowDetail = (flowId) => axios({ 5 | url: `/api/flow/${flowId}`, 6 | }) 7 | 8 | export default getFlowDetail 9 | -------------------------------------------------------------------------------- /frontend/src/apis/banner.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | const PREFIX = '/plugins/api_coverage/api' 4 | 5 | // importBase 6 | export const uploadBase = (upLoadFile) => { 7 | const url = `${PREFIX}/importBase` 8 | return axios({ 9 | url, 10 | method: 'POST', 11 | data: upLoadFile, 12 | }) 13 | } 14 | 15 | // resumeTest 16 | export const resumeTest = (upLoadFile) => { 17 | const url = `${PREFIX}/resumeTest` 18 | return axios({ 19 | url, 20 | method: 'POST', 21 | data: upLoadFile, 22 | }) 23 | } 24 | 25 | // saveResult 26 | export const saveResult = (resultName) => { 27 | const url = `${PREFIX}/saveResult` 28 | return axios({ 29 | url, 30 | method: 'POST', 31 | data: resultName, 32 | }) 33 | } 34 | 35 | // clearTest 36 | export const clearTest = () => { 37 | const url = `${PREFIX}/clearResult` 38 | return axios({ 39 | url, 40 | method: 'GET', 41 | }) 42 | } 43 | 44 | // getFilterConf 45 | export const getFilterConf = () => { 46 | const url = `${PREFIX}/getFilterConf` 47 | return axios({ 48 | url, 49 | method: 'GET', 50 | }) 51 | } 52 | 53 | // setFilterConf 54 | export const setFilterConf = (filterFile) => { 55 | const url = `${PREFIX}/setFilterConf` 56 | return axios({ 57 | url, 58 | method: 'POST', 59 | data: filterFile, 60 | }) 61 | } 62 | -------------------------------------------------------------------------------- /frontend/src/apis/index.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | export * from '@/apis/banner' 4 | export * from '@/apis/info' 5 | export * from '@/apis/apiList' 6 | 7 | const PREFIX = '/plugins/api_coverage/api' 8 | 9 | // getTest 10 | export const getTest = () => { 11 | const url = `${PREFIX}/getTest` 12 | return axios({ 13 | url, 14 | method: 'GET', 15 | }) 16 | } 17 | 18 | // getCoverage 19 | export const getCoverage = () => { 20 | const url = `${PREFIX}/getCoverage` 21 | return axios({ 22 | url, 23 | method: 'GET', 24 | }) 25 | } 26 | -------------------------------------------------------------------------------- /frontend/src/apis/info.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | const PREFIX = '/plugins/api_coverage/api' 4 | 5 | // getBaseInfo 6 | const getBaseInfo = () => { 7 | const url = `${PREFIX}/baseInfo` 8 | return axios({ 9 | url, 10 | method: 'GET', 11 | }) 12 | } 13 | export default getBaseInfo 14 | -------------------------------------------------------------------------------- /frontend/src/components/CodeEditor.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 104 | -------------------------------------------------------------------------------- /frontend/src/components/apiList.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | {{item.label}} 9 | 10 | 11 | 12 | 13 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 145 | 146 | 153 | -------------------------------------------------------------------------------- /frontend/src/components/banner.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Import Base 12 | 20 | Resume Test 28 | 36 | 37 | Save Result 45 | Clear Test 53 | Filtering Rules 61 | 67 | 68 | 69 | 77 | 78 | 79 | 80 | 88 | 89 | 90 | 91 | 92 | 311 | 312 | 325 | -------------------------------------------------------------------------------- /frontend/src/components/coverage.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Coverage 5 | 6 | 7 | 8 | 9 | {{ item }} 10 | 11 | 12 | 13 | 14 | 15 | Total 16 | 17 | {{ coverageData.test_len + "/" + coverageData.len }} 18 | 19 | 20 | 25 | 26 | 27 | 28 | {{ "P" + item.label }} 29 | 30 | {{ item.test_len + "/" + item.len }} 31 | 32 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 83 | 84 | 91 | -------------------------------------------------------------------------------- /frontend/src/components/flowDetail.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 18 | 19 | 20 | 21 | 22 | 107 | 108 | 116 | -------------------------------------------------------------------------------- /frontend/src/components/info.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | Info 4 | business : {{business}} 5 | versionCode : {{versionCode}} 6 | versionName : {{versionName}} 7 | 8 | 9 | 10 | 26 | 27 | 34 | -------------------------------------------------------------------------------- /frontend/src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import ViewUI from 'view-design' 3 | import io from 'socket.io-client' 4 | import App from './App.vue' 5 | import store from './store' 6 | import 'view-design/dist/styles/iview.css' 7 | 8 | Vue.config.productionTip = false 9 | Vue.use(ViewUI) 10 | Vue.prototype.$io = io() 11 | new Vue({ 12 | store, 13 | render: (h) => h(App), 14 | }).$mount('#app') 15 | -------------------------------------------------------------------------------- /frontend/src/store/apiList.js: -------------------------------------------------------------------------------- 1 | import getFlowDetail from '../apis/apiList' 2 | 3 | export default { 4 | state: { 5 | focusedFlowDetail: null, 6 | }, 7 | mutations: { 8 | setFocusedFlowDetail(state, focusedFlowDetail) { 9 | state.focusedFlowDetail = focusedFlowDetail 10 | }, 11 | }, 12 | actions: { 13 | loadFlowDetail({ commit }, flowId) { 14 | getFlowDetail(flowId) 15 | .then((response) => { 16 | commit('setFocusedFlowDetail', response.data.data) 17 | }) 18 | }, 19 | }, 20 | } 21 | -------------------------------------------------------------------------------- /frontend/src/store/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Vuex from 'vuex' 3 | import * as apis from '@/apis' 4 | import info from '@/store/info' 5 | import apiList from '@/store/apiList' 6 | 7 | Vue.use(Vuex) 8 | 9 | export default new Vuex.Store({ 10 | state: { 11 | detailData: [], 12 | coverageData: {}, 13 | showedAPIData: [], 14 | }, 15 | mutations: { 16 | setDetailData(state, detailData) { 17 | state.detailData = detailData 18 | }, 19 | setCoverageData(state, coverageData) { 20 | state.coverageData = coverageData 21 | }, 22 | setShowedAPIData(state, showedAPIData) { 23 | state.showedAPIData = showedAPIData 24 | }, 25 | }, 26 | actions: { 27 | loadDetailData(context) { 28 | apis 29 | .getTest() 30 | .then((response) => { 31 | context.commit('setDetailData', response.data.test_data) 32 | context.commit('setShowedAPIData', response.data.test_data) 33 | }) 34 | .catch(() => { 35 | this.$Notice.open({ title: 'loadDetailData error!' }) 36 | }) 37 | }, 38 | loadCoverageData(context) { 39 | apis 40 | .getCoverage() 41 | .then((response) => { 42 | context.commit('setCoverageData', response.data.coverage) 43 | }) 44 | .catch(() => { 45 | this.$Notice.open({ title: 'loadCoverageData error!' }) 46 | }) 47 | }, 48 | setShowedAPIData(context, showedAPIData) { 49 | context.commit('setShowedAPIData', showedAPIData) 50 | }, 51 | }, 52 | modules: { 53 | info, 54 | apiList, 55 | }, 56 | }) 57 | -------------------------------------------------------------------------------- /frontend/src/store/info.js: -------------------------------------------------------------------------------- 1 | import getBaseInfo from '../apis/info' 2 | 3 | export default { 4 | state: { 5 | business: '', 6 | versionCode: '', 7 | versionName: '', 8 | }, 9 | mutations: { 10 | setBusiness(state, business) { 11 | state.business = business 12 | }, 13 | setVersionCode(state, versionCode) { 14 | state.versionCode = versionCode 15 | }, 16 | setVersionName(state, versionName) { 17 | state.versionName = versionName 18 | }, 19 | }, 20 | actions: { 21 | loadBaseInfo(context) { 22 | getBaseInfo() 23 | .then((response) => { 24 | if (response.data.code === 1000) { 25 | context.commit('setBusiness', response.data.business) 26 | context.commit('setVersionCode', response.data.version_code) 27 | context.commit('setVersionName', response.data.version_name) 28 | } else { 29 | this.$Notice.open({ title: 'loadBaseInfo failed!' }) 30 | } 31 | }) 32 | .catch(() => { 33 | this.$Notice.open({ title: 'loadBaseInfo failed!' }) 34 | }) 35 | }, 36 | }, 37 | } 38 | -------------------------------------------------------------------------------- /frontend/src/utils/jsonpath.js: -------------------------------------------------------------------------------- 1 | const colType = { Object, Array } 2 | 3 | function pathToString(path) { 4 | let s = '$' 5 | try { 6 | for (const frame of path) { 7 | if (frame.colType === colType.Object) { 8 | if (!frame.key.match(/^[a-zA-Z$_][a-zA-Z\d$_]*$/)) { 9 | s += `["${frame.key}"]` 10 | } else { 11 | if (s.length) { 12 | s += '.' 13 | } 14 | s += frame.key 15 | } 16 | } else { 17 | s += `[${frame.index}]` 18 | } 19 | } 20 | return s 21 | } catch (ex) { 22 | return '' 23 | } 24 | } 25 | 26 | function isEven(n) { 27 | return n % 2 === 0 28 | } 29 | 30 | // Find the next end quote 31 | function findEndQuote(text, i) { 32 | while (i < text.length) { 33 | if (text[i] === '"') { 34 | let bt = i 35 | // Handle backtracking to find if this quote escaped (or, if the escape is escaping a slash) 36 | while (bt >= 0 && text[bt] === '\\') { 37 | bt -= 1 38 | } 39 | if (isEven(i - bt)) { 40 | break 41 | } 42 | } 43 | i += 1 44 | } 45 | return i 46 | } 47 | 48 | function readString(text, pos) { 49 | let i = pos + 1 50 | i = findEndQuote(text, i) 51 | const textpos = { 52 | text: text.substring(pos + 1, i), 53 | pos: i + 1, 54 | } 55 | return textpos 56 | } 57 | 58 | function getJsonPath(text, offSet) { 59 | let pos = 0 60 | const stack = [] 61 | let isInKey = false 62 | while (pos < offSet) { 63 | const startPos = pos 64 | let s = null 65 | let newPos = null 66 | let readStringReturnObj = null 67 | switch (text[pos]) { 68 | case '"': 69 | readStringReturnObj = readString(text, pos) 70 | s = readStringReturnObj.text 71 | newPos = readStringReturnObj.pos 72 | if (stack.length) { 73 | const frame = stack[stack.length - 1] 74 | if (frame.colType === colType.Object && isInKey) { 75 | frame.key = s 76 | isInKey = false 77 | } 78 | } 79 | pos = newPos 80 | break 81 | case '{': 82 | stack.push({ colType: colType.Object }) 83 | isInKey = true 84 | break 85 | case '[': 86 | stack.push({ colType: colType.Array, index: 0 }) 87 | break 88 | case '}': 89 | case ']': 90 | stack.pop() 91 | break 92 | case ',': 93 | if (stack.length) { 94 | const frame = stack[stack.length - 1] 95 | if (frame.colType === colType.Object) { 96 | isInKey = true 97 | } else { 98 | frame.index += 1 99 | } 100 | } 101 | break 102 | default: 103 | break 104 | } 105 | if (pos === startPos) { 106 | pos += 1 107 | } 108 | } 109 | return pathToString(stack) 110 | } 111 | 112 | export default getJsonPath 113 | -------------------------------------------------------------------------------- /frontend/vue.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | configureWebpack: { 3 | devtool: 'source-map', 4 | plugins: [], 5 | }, 6 | productionSourceMap: false, 7 | publicPath: process.env.NODE_ENV === 'production' ? './dist' : '/', 8 | outputDir: '../lyrebird_api_coverage/dist', 9 | devServer: { 10 | proxy: 'http://localhost:9090', 11 | }, 12 | } 13 | -------------------------------------------------------------------------------- /images/introduce.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meituan-Dianping/lyrebird-api-coverage/d5d37e740e8bafa0d493eced22044f516e7e0937/images/introduce.png -------------------------------------------------------------------------------- /images/main.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meituan-Dianping/lyrebird-api-coverage/d5d37e740e8bafa0d493eced22044f516e7e0937/images/main.png -------------------------------------------------------------------------------- /lyrebird_api_coverage/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meituan-Dianping/lyrebird-api-coverage/d5d37e740e8bafa0d493eced22044f516e7e0937/lyrebird_api_coverage/__init__.py -------------------------------------------------------------------------------- /lyrebird_api_coverage/api.py: -------------------------------------------------------------------------------- 1 | import json 2 | import lyrebird 3 | import os 4 | from flask import request, jsonify, Response, stream_with_context 5 | from lyrebird_api_coverage.client.context import app_context 6 | from lyrebird_api_coverage.handlers.base_source_handler import BaseDataHandler 7 | from lyrebird_api_coverage.client.merge_algorithm import mergeAlgorithm 8 | from lyrebird_api_coverage.handlers.filter_handler import FilterHandler 9 | from lyrebird_api_coverage.handlers.import_file_handler import ImportHandler 10 | from lyrebird_api_coverage.handlers.result_handler import ResultHandler, PLUGINS_DUMP_DIR 11 | from lyrebird import context 12 | 13 | def generate(data): 14 | rtext = json.dumps(data) 15 | yield rtext 16 | 17 | # 获取内存里保存的测试结果API 18 | # /getTest 19 | def get_test_data(): 20 | return context.make_ok_response(test_data=app_context.merge_list) 21 | 22 | # 获取内存里保存的测试覆盖率信息 23 | # /getCoverage 24 | def get_coverage(): 25 | return context.make_ok_response(coverage = app_context.coverage) 26 | 27 | # 保存测试数据在本地 28 | # /saveResult 29 | def save_result(): 30 | # 传入文件名 31 | filename = request.form.get('result_name') 32 | ResultHandler().save_result(filename) 33 | lyrebird.publish('api_coverage', 'operation', name='save_result') 34 | return context.make_ok_response() 35 | 36 | #续传测试结果 37 | # /resumeTest 38 | def resume_test(): 39 | # resp = ResultHandler().resume_test() 40 | resp = ImportHandler().import_result_handler() 41 | return resp 42 | 43 | # 清空测试缓存结果 44 | # /clearResult 45 | def clear_result(): 46 | ResultHandler().clear_cache_result() 47 | # 获取基准文件 48 | base_dict = BaseDataHandler().get_base_source() 49 | # 初始化正常会进行数据的处理:覆盖率初始化 & API LIST初始化 50 | if not isinstance(base_dict, Response): 51 | mergeAlgorithm.first_result_handler(base_dict) 52 | mergeAlgorithm.coverage_arithmetic(base_dict) 53 | lyrebird.publish('api_coverage', 'operation', name='clear_result') 54 | return context.make_ok_response() 55 | 56 | # 导入base json文件 57 | # /importBase 58 | def import_base(): 59 | resp = ImportHandler().import_base_handler() 60 | return resp 61 | 62 | # 获取filter的conf文件 63 | # /getFilterConf 64 | def get_filter_conf(): 65 | msg = FilterHandler().get_filer_conf() 66 | # 如果返回的string包含报错信息,则是报错 67 | if isinstance(msg, str): 68 | return context.make_fail_response(msg) 69 | else: 70 | return jsonify(msg) 71 | 72 | # 覆盖配置filter conf文件 73 | # /setFilterConf 74 | def set_filter_conf(): 75 | filter_data = request.form.get('filter_data') 76 | try: 77 | resp = FilterHandler().save_filer_conf(json.loads(filter_data)) 78 | lyrebird.publish('api_coverage', 'operation', name='set_filter') 79 | except Exception as e: 80 | lyrebird.publish('api_coverage', 'error', name='set_filter') 81 | return context.make_fail_response("传入的非json文件" + str(repr(e))) 82 | return resp 83 | 84 | # overbridge dump 信息用的API 85 | # /dump 86 | def dump(): 87 | filename = 'api_coverage' 88 | ResultHandler().dump_info(filename) 89 | return jsonify( 90 | [{'name': str(filename) + '.json', 'path': os.path.join(PLUGINS_DUMP_DIR, str(filename) + '.json')}]) 91 | 92 | # base info 93 | # /baseInfo 94 | def get_base_info(): 95 | return context.make_ok_response( 96 | business=app_context.business, 97 | version_name=app_context.version_name, 98 | version_code=app_context.version_code 99 | ) 100 | -------------------------------------------------------------------------------- /lyrebird_api_coverage/client/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meituan-Dianping/lyrebird-api-coverage/d5d37e740e8bafa0d493eced22044f516e7e0937/lyrebird_api_coverage/client/__init__.py -------------------------------------------------------------------------------- /lyrebird_api_coverage/client/config.py: -------------------------------------------------------------------------------- 1 | import lyrebird 2 | import os 3 | import json 4 | import codecs 5 | 6 | storage = lyrebird.get_plugin_storage() 7 | CONFIG_FILE = os.path.abspath(os.path.join(storage, 'conf.json')) 8 | DEFAULT_CONF_FILE = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', './default_conf/conf.json')) 9 | 10 | 11 | class Config: 12 | 13 | def __init__(self): 14 | self.base_ssh = None 15 | self.base_path = None 16 | 17 | 18 | def load(): 19 | if not os.path.exists(CONFIG_FILE): 20 | f_from = codecs.open(DEFAULT_CONF_FILE, 'r', 'utf-8') 21 | f_to = codecs.open(CONFIG_FILE, 'w', 'utf-8') 22 | f_to.write(f_from.read()) 23 | f_to.close() 24 | f_from.close() 25 | conf_data = json.loads(codecs.open(CONFIG_FILE, 'r', 'utf-8').read()) 26 | conf = Config() 27 | conf.__dict__ = conf_data 28 | return conf 29 | -------------------------------------------------------------------------------- /lyrebird_api_coverage/client/context.py: -------------------------------------------------------------------------------- 1 | """ 2 | 上下文,保存缓存数据 3 | """ 4 | class Context: 5 | def __init__(self): 6 | # base url list 7 | self.base_list = [] 8 | # user tested url list(base包含的) 9 | self.user_list = [] 10 | # 用户访问的原始url 11 | self.user_org_list = [] 12 | # 前端需要的list,base数据和user访问数据 merge之后的结果list 13 | self.merge_list = [] 14 | # 参数校验需要的dict 15 | self.path_param_dic = {} 16 | # 优先级级名list 17 | self.priority_list = [] 18 | # coverage 覆盖率信息 19 | self.coverage = {} 20 | # base文件对应的sha1 21 | self.base_sha1 = '' 22 | # base文件对应的filename 23 | self.filename = '' 24 | # 过滤规则包含host和regular 25 | self.filter_dic = {} 26 | # device & APP信息 27 | self.info = {} 28 | # 来自base文件的信息 29 | self.business = '' 30 | self.version_name = '' 31 | self.version_code = None 32 | # user_info 33 | self.user_info = {} 34 | # 记录请求最后的时间,避免频繁emit io消息 35 | self.endtime = 0 36 | # 记录上次coverage变化的时间,避免频繁emit io消息 37 | self.covtime = 0 38 | # 时间间隔,每隔指定时间触发1次socket io消息,防止刷新频繁 39 | self.SOCKET_PUSH_INTERVAL = 1 40 | # 是否使用接口请求实时base数据 41 | self.is_api_base_data = False 42 | # category信息 43 | self.category = '' 44 | 45 | 46 | # 单例模式 47 | app_context = Context() 48 | -------------------------------------------------------------------------------- /lyrebird_api_coverage/client/event_subscibe.py: -------------------------------------------------------------------------------- 1 | """ 2 | 事件处理器 3 | 接收用户信息和设备信息等 4 | """ 5 | 6 | import lyrebird 7 | from lyrebird_api_coverage.client.context import app_context 8 | 9 | 10 | def event_handler(event): 11 | app_context.info = event.get('message') 12 | 13 | 14 | def user_handler(event): 15 | app_context.user_info = event.get('message') 16 | 17 | 18 | def event_subscribe(): 19 | lyrebird.subscribe('device_info', event_handler) 20 | lyrebird.subscribe('user_info', user_handler) 21 | -------------------------------------------------------------------------------- /lyrebird_api_coverage/client/filter_url.py: -------------------------------------------------------------------------------- 1 | import re 2 | from lyrebird_api_coverage.client.context import app_context 3 | from lyrebird_api_coverage.handlers.filter_handler import FilterHandler 4 | 5 | """ 6 | 过滤器,滤除不关注的API 7 | """ 8 | class Filter: 9 | def filter_host(self, url, host_list): 10 | if url.split('/')[0] in host_list: 11 | return True 12 | else: 13 | return False 14 | 15 | def filter_re(self, url, pattern_list): 16 | flag = False 17 | for pattern_item in pattern_list: 18 | if re.search(pattern_item, url) is not None: 19 | return True 20 | else: 21 | flag = False 22 | return flag 23 | 24 | def filter_all(self, url): 25 | # 如果没有init filter conf 做个处理 26 | if not app_context.filter_dic: 27 | FilterHandler().get_filer_conf() 28 | host_list = app_context.filter_dic.get('exclude').get('host') 29 | pattern_list = app_context.filter_dic.get('exclude').get('regular') 30 | host_filter = self.filter_host(url, host_list) 31 | regular_filter = self.filter_re(url, pattern_list) 32 | if host_filter or regular_filter is True: 33 | return True 34 | else: 35 | return False 36 | -------------------------------------------------------------------------------- /lyrebird_api_coverage/client/format_url.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | 4 | def format_api(url): 5 | # 正则匹配例如 11,22,33 或 只有 11 这类的数据,同时排除出现 123test 这样的规则匹配,加上/作为过滤标准,或者以/123结尾的匹配 6 | # m = re.sub("(\/(\d+,){0,}\d+\/)", "/{num}/", url) 7 | # api = re.sub("(\/(\d+,){0,}\d+\Z)", "/{num}", m) 8 | m = re.sub('(\/(0|[1-9][0-9]*|-[1-9][0-9]*)\/)', "/{num}/", url) 9 | api = re.sub('(\/(0|[1-9][0-9]*|-[1-9][0-9]*))', "/{num}", m) 10 | return api 11 | 12 | 13 | def format_api_source(url): 14 | # 正则匹配例如 apitrip.meituan.com/volga/api/v1/{trip}/home/{aaaa}/z 15 | short_url = re.sub("(\/\{.*?\})+", "/{num}", url) 16 | return short_url 17 | -------------------------------------------------------------------------------- /lyrebird_api_coverage/client/jsonscheme.py: -------------------------------------------------------------------------------- 1 | import collections 2 | 3 | from jsonschema import validate 4 | 5 | schema = { 6 | "type": "object", 7 | "properties": { 8 | "business": { 9 | "type": "string", 10 | }, 11 | "version_code": { 12 | "type": "integer", 13 | }, 14 | "version_name": { 15 | "type": "string", 16 | }, 17 | "api_list": { 18 | "type": "array", 19 | "uniqueItems": True, 20 | "items": { 21 | "type": "object", 22 | "properties": { 23 | "url": {"description": "The unique identifier for a product", "type": "string", }, 24 | "desc": {"description": "Name of the product", "type": "string"}, 25 | "priority": {"type": "integer", } 26 | }, 27 | "required": ["url", "desc", "priority"] 28 | } 29 | } 30 | }, 31 | "required": ["business", "version_code", "version_name", "api_list"] 32 | } 33 | 34 | result_schema = { 35 | "type": "object", 36 | "properties": { 37 | "file_name": {"type": "string"}, 38 | "base_sha1": {"type": "string"}, 39 | "coverage": { 40 | "type": "object", 41 | "properties": { 42 | "name": {"type": "string"}, 43 | "total": {"type": "number"}, 44 | "len": {"type": "integer"}, 45 | "test_len": {"type": "integer"}, 46 | "priorities": { 47 | "type": "array", 48 | "uniqueItems": True, 49 | "items": { 50 | "type": "object", 51 | "properties": { 52 | "label": {"type": "integer"}, 53 | "value": {"type": "number"}, 54 | "len": {"type": "integer"}, 55 | "test_len": {"type": "integer"}, 56 | }, 57 | "required": ["label", "value", "len", "test_len"] 58 | } 59 | } 60 | }, 61 | "required": ["name", "total", "len", "test_len","priorities"] 62 | }, 63 | "test_data": { 64 | "type": "array", 65 | "uniqueItems": True, 66 | "items": { 67 | "type": "object", 68 | "properties": { 69 | "url": {"description": "The unique identifier for a product", "type": "string", }, 70 | "desc": {"description": "Name of the product", "type": "string"}, 71 | "priority": {"type": ["integer", "null"]}, 72 | "count": {"type": "integer", }, 73 | "status": {"type": "integer", }, 74 | "id": {"type": "string"} 75 | }, 76 | "required": ["url", "desc", "priority"] 77 | } 78 | } 79 | }, 80 | "required": ["file_name", "base_sha1", "coverage", "test_data"] 81 | } 82 | 83 | filter_schema = { 84 | "type": "object", 85 | "properties": { 86 | "exclude": { 87 | "type": "object", 88 | "properties": { 89 | "host": { 90 | "type": "array", 91 | "uniqueItems": True, 92 | "items": {"type": "string", }, 93 | }, 94 | "regular": { 95 | "type": "array", 96 | "uniqueItems": True, 97 | "items": {"type": "string", } 98 | }, 99 | }, 100 | "required": ["host", "regular"] 101 | } 102 | }, 103 | "required": ["exclude"] 104 | } 105 | 106 | 107 | def check_url_redundant(obj): 108 | repeat_urls = [] 109 | url_list = list(map(lambda x: x.get('url'), obj.get('api_list'))) 110 | url_count = dict(collections.Counter(url_list)) 111 | for k, v in url_count.items(): 112 | if v > 1: 113 | repeat_urls.append(k) 114 | return repeat_urls 115 | 116 | 117 | def check_schema(obj): 118 | validate(obj, schema) 119 | 120 | 121 | def check_filter_schema(obj): 122 | validate(obj, filter_schema) 123 | 124 | 125 | def check_result_schema(obj): 126 | validate(obj, result_schema) -------------------------------------------------------------------------------- /lyrebird_api_coverage/client/load_base.py: -------------------------------------------------------------------------------- 1 | import codecs 2 | import hashlib 3 | import json 4 | import lyrebird 5 | from pathlib import Path 6 | from lyrebird.log import get_logger 7 | import os 8 | import imp 9 | import traceback 10 | 11 | from lyrebird_api_coverage.client.context import app_context 12 | 13 | PLUGINS_CONF_DIR = lyrebird.get_plugin_storage() 14 | DEFAULT_BASE = os.path.join(PLUGINS_CONF_DIR, 'base.json') 15 | CURRENT_DIR = os.path.dirname(__file__) 16 | 17 | logger = get_logger() 18 | 19 | 20 | def get_file_sha1(path): 21 | with open(path, 'rb') as f: 22 | sha1obj = hashlib.sha1() 23 | sha1obj.update(f.read()) 24 | hash_sha1 = sha1obj.hexdigest() 25 | return hash_sha1 26 | 27 | 28 | def auto_load_base(): 29 | lyrebird_conf = lyrebird.context.application.conf 30 | # 读取指定base文件,写入到base.json 31 | if lyrebird_conf.get('hunter.base'): 32 | base_path = lyrebird_conf.get('hunter.base') 33 | base_path_obj = Path(base_path) 34 | if not base_path_obj.is_file(): 35 | return 36 | # 判断是否需要实时获取接口数据 37 | if base_path_obj.suffix == '.py': 38 | try: 39 | init_base_file = imp.load_source('load_base', str(base_path_obj)) 40 | except Exception: 41 | logger.warning(f'Failed to load the file, {traceback.format_exc()}') 42 | return 43 | if not hasattr(init_base_file, 'load_api_base'): 44 | logger.warning(f'load_api_base does not exist') 45 | return 46 | if not callable(init_base_file.load_api_base): 47 | logger.warning(f'The method does not exist') 48 | return 49 | app_context.is_api_base_data = True 50 | return init_base_file.load_api_base() 51 | else: 52 | base = codecs.open(base_path, 'r', 'utf-8').read() 53 | f = codecs.open(DEFAULT_BASE, 'w', 'utf-8') 54 | f.write(base) 55 | f.close() 56 | app_context.base_sha1 = get_file_sha1(DEFAULT_BASE) 57 | return json.loads(base) 58 | # 通过本地默认base文件获取base 59 | elif not os.path.exists(DEFAULT_BASE): 60 | copy_file(DEFAULT_BASE) 61 | with codecs.open(DEFAULT_BASE, 'r', 'utf-8') as f: 62 | json_obj = json.load(f) 63 | app_context.base_sha1 = get_file_sha1(DEFAULT_BASE) 64 | return json_obj 65 | 66 | 67 | def copy_file(target_path): 68 | os.path.abspath(os.path.join(CURRENT_DIR, '..', './default_conf/base.json')) 69 | f_from = codecs.open(os.path.abspath(os.path.join(CURRENT_DIR, '..', './default_conf/base.json')), 'r', 'utf-8') 70 | f_to = codecs.open(target_path, 'w', 'utf-8') 71 | f_to.write(f_from.read()) 72 | f_to.close() 73 | f_from.close() 74 | -------------------------------------------------------------------------------- /lyrebird_api_coverage/client/merge_algorithm.py: -------------------------------------------------------------------------------- 1 | import lyrebird 2 | from lyrebird_api_coverage.client import format_url 3 | from lyrebird_api_coverage.client.context import app_context 4 | import time 5 | 6 | 7 | class MergeAlgorithm: 8 | 9 | def first_result_handler(self, json_obj): 10 | """ 11 | 初始化/首次/切换base之后 需要重新生成base_list,merge_list 12 | 注意没有初始化coverage 13 | coverage初始化需要在getCoverage接口实现 14 | """ 15 | 16 | # 清空 17 | app_context.user_list.clear() 18 | app_context.base_list.clear() 19 | app_context.user_org_list.clear() 20 | # 清空coverage 21 | app_context.coverage.clear() 22 | # 清空缓存list 23 | app_context.merge_list.clear() 24 | # 清空 参数列表中间处理数据 25 | app_context.path_param_dic.clear() 26 | # 初始化base数据,format成需要的数据格式 27 | self.init_basedata_handler(json_obj) 28 | # 初始化coverage数据 29 | self.coverage_arithmetic(json_obj) 30 | # 获取所有的base URL 31 | app_context.base_list = list(map(lambda x: x.get('url'), json_obj.get('api_list'))) 32 | 33 | def init_basedata_handler(self, dic): 34 | # for k, v in dic.items(): 35 | # url_dic = {'url': k, 'desc': v.get('desc'), 'priority': v.get('priority'), 'count': 0, 'status': 0, 36 | # 'org': []} 37 | # app_context.merge_list.append(url_dic) 38 | if app_context.is_api_base_data: 39 | # 如果接口获取base数据,同步merge_list内容 40 | app_context.merge_list = dic.get('api_list') 41 | else: 42 | dict2 = {'count': 0, 'status': 0, 'id': ''} 43 | for item in dic.get('api_list'): 44 | # 处理带参数的情况 45 | if '?' in item['url']: 46 | path = item['url'].split('?')[0].lower() 47 | params = item['url'].split('?')[1].split('&') 48 | param_dic = {} 49 | for i in params: 50 | key = i.split('=')[0] 51 | val = i.split('=')[1] 52 | param_dic[key] = val 53 | 54 | if app_context.path_param_dic.get(path): 55 | app_context.path_param_dic[path].append({'url': item['url'], 'params': param_dic, 56 | 'url_base': format_url.format_api_source( 57 | item.get('url')).lower()}) 58 | else: 59 | app_context.path_param_dic[path] = [{'url': item['url'], 'params': param_dic, 60 | 'url_base': format_url.format_api_source( 61 | item.get('url')).lower()}] 62 | # format base源 同时变成大小写归一化,变小写 63 | item['url'] = format_url.format_api_source(item.get('url')).lower() 64 | item.update(dict2) 65 | app_context.merge_list.append(item) 66 | 67 | def merge_handler_new(self, user_url, path_id, category): 68 | """ 69 | status=0 base中包含未覆盖,status=1 base中包含已覆盖,status=2 base中不包含且覆盖到的; 70 | path_id表示URL的handler_context的唯一标识,查看详情用 71 | """ 72 | 73 | # 在list中筛选出想要的数据,筛选结果直接取0即可 74 | specific_filter_list = list(filter(lambda x: x.get('url') == user_url, app_context.merge_list)) 75 | 76 | # 判断筛选出来的list是否为空,即是否在list中存在 77 | if specific_filter_list: 78 | specific_dic = specific_filter_list[0] 79 | # 移除掉对应的数据为插入index0的位置做前置处理 80 | app_context.merge_list.remove(specific_dic) 81 | # 根据数据源,进行业务处理 82 | if app_context.is_api_base_data: 83 | category_dic = specific_dic.get('category') 84 | for p in category_dic: 85 | if category == p['name'] and p['status'] == 0: 86 | p['status'] = 1 87 | p['count'] += 1 88 | p['id'] = path_id 89 | if specific_dic['status'] == 0: 90 | specific_dic['status'] = 1 91 | # 把首次覆盖到的API,放入user_list里面 92 | app_context.user_list.append(user_url) 93 | else: 94 | # 非接口获取base数据 95 | if specific_dic['status'] == 0: 96 | specific_dic['status'] = 1 97 | # 把首次覆盖到的API,放入user_list里面 98 | app_context.user_list.append(user_url) 99 | # count +1 100 | specific_dic['count'] += 1 # 插入原始url # specific_dic['org'].append(org_url) 101 | specific_dic['id'] = path_id 102 | else: 103 | if app_context.is_api_base_data: 104 | specific_dic = { 105 | 'url': user_url, 106 | 'desc': '', 107 | 'priority': None, 108 | 'status': 2, 109 | 'count': 1, 110 | 'category': [] 111 | } 112 | specific_dic['category'].append({ 113 | 'id': None, 114 | 'name': category, 115 | 'status': 2, 116 | 'count': 1 117 | }) 118 | else: 119 | specific_dic = {'url': user_url, 'desc': '', 'priority': None, 'count': 1, 'status': 2, 'id': path_id} 120 | # 插入到 index=0 的位置 121 | app_context.merge_list.insert(0, specific_dic) 122 | 123 | def coverage_handler(self): 124 | """ 125 | 总体覆盖率 126 | """ 127 | # 获取handle前的历史覆盖率为做对比用 128 | history_coverage = app_context.coverage['total'] 129 | test_len = len(list(filter(lambda x: x.get('status') == 1, app_context.merge_list))) 130 | if app_context.coverage['len'] == 0: 131 | coverage = 0 132 | else: 133 | coverage = round(test_len / app_context.coverage['len'] * 100, 2) 134 | # 为了传给Overbridge的socket信息format数据格式 135 | app_context.coverage['total'] = coverage 136 | # 覆盖率有变化才emit & publish 覆盖率的变化消息给API-Coverage前端,overbridge前端,和消息总线 137 | if not history_coverage == coverage: 138 | handler_time = time.time() 139 | # 限制频繁emit io msg,在两次之间大于指定时间间隔才会emit 140 | if handler_time - app_context.covtime > app_context.SOCKET_PUSH_INTERVAL: 141 | lyrebird.emit('coverage message', app_context.coverage.get('total'), namespace='/api_coverage') 142 | app_context.covtime = handler_time 143 | by_priority = [p.get('value') for p in app_context.coverage['priorities']] 144 | lyrebird.publish('coverage', 145 | dict( 146 | name='coverage', 147 | value=app_context.coverage.get('total'), 148 | by_priority=by_priority) 149 | ) 150 | app_context.coverage['test_len'] = test_len 151 | # 各优先级对应覆盖率 152 | for item_dic in app_context.coverage.get('priorities'): 153 | item_length = item_dic.get('len') 154 | test_item_length = len(list( 155 | filter(lambda x: x.get('priority') == item_dic.get('label') and x.get('status') == 1, 156 | app_context.merge_list))) 157 | if item_length == 0: 158 | coverage = 0 159 | else: 160 | coverage = round(test_item_length / item_length * 100, 2) 161 | item_dic['value'] = coverage 162 | item_dic['test_len'] = test_item_length 163 | 164 | def init_resume_data(self, dic): 165 | app_context.user_list = [] 166 | app_context.base_list = [] 167 | for k, v in dic.items(): 168 | if v['status'] == 1: 169 | app_context.user_list.append(k) 170 | app_context.base_list.append(k) 171 | elif v['status'] == 0: 172 | app_context.base_list.append(k) 173 | 174 | # 获取优先级初始info,优先级分几个,以及各个优先级的list长度,传入的也是conf文件 175 | def coverage_arithmetic(self, dic): 176 | url_info_list = dic.get('api_list') 177 | # 获取优先级list,非空 178 | priority_list = list(set(list(map(lambda x: x.get('priority'), url_info_list)))) 179 | # 去除空值 180 | if list(filter(lambda x: x == '', priority_list)): 181 | priority_list.remove('') 182 | # 排序 183 | app_context.priority_list = sorted(priority_list) 184 | # init coverage 原始数据结构 total总体数据 185 | app_context.coverage['name'] = 'coverage' 186 | app_context.coverage['total'] = 0 187 | app_context.coverage['len'] = len(url_info_list) 188 | app_context.coverage['test_len'] = 0 189 | app_context.coverage['priorities'] = [] 190 | # 各个优先级的init数据 191 | for item in app_context.priority_list: 192 | item_length = len(list(filter(lambda x: x.get('priority') == item, url_info_list))) 193 | coverage = 0 194 | app_context.coverage['priorities'].append( 195 | {'label': item, 'value': coverage, 'len': item_length, 'test_len': 0}) 196 | 197 | # 对import的打算resume的result和 缓存里面的测试结果进行merge 198 | def merge_resume(self, result_list): 199 | # 在list中筛选出url,筛选结果直接取0即可 200 | cache_list = app_context.merge_list 201 | # 取出url信息,组成list 202 | cache_url_list = list(map(lambda x: x.get('url'), cache_list)) 203 | result_url_list = list(map(lambda x: x.get('url'), result_list)) 204 | # 找到交集 205 | intersection_list = list(set(cache_url_list).intersection(set(result_url_list))) 206 | # 对交集进行处理 207 | for url in intersection_list: 208 | cache_spec = list(filter(lambda x: x.get('url') == url, cache_list))[0] 209 | result_spec = list(filter(lambda x: x.get('url') == url, result_list))[0] 210 | spec_dict = list(filter(lambda x: x.get('url') == url, app_context.merge_list))[0] 211 | app_context.merge_list.remove(spec_dict) 212 | # 修改count status org_url 213 | spec_dict['count'] = cache_spec.get('count') + result_spec.get('count') 214 | if spec_dict['status'] == 0 and spec_dict['count'] != 0: 215 | spec_dict['status'] = 1 216 | app_context.merge_list.insert(0, spec_dict) 217 | # 找到差集 218 | diff_url_list = list(set(result_url_list) - set(cache_url_list)) 219 | # 差集处理(对result中存在,但cache不存在的)status=2的情景进行extend处理 220 | diff_list = list(filter(lambda x: True if x.get('url') in diff_url_list else False, result_list)) 221 | app_context.merge_list.extend(diff_list) 222 | # 重新计算覆盖率 223 | self.coverage_handler() 224 | 225 | 226 | mergeAlgorithm = MergeAlgorithm() 227 | -------------------------------------------------------------------------------- /lyrebird_api_coverage/client/report.py: -------------------------------------------------------------------------------- 1 | from lyrebird_api_coverage.client.context import app_context 2 | from lyrebird import publish 3 | 4 | """ 5 | 上报处理器,用户请求行为上报到ELK中 6 | ps:需要在lyrebird中设定reporter相关配置 7 | """ 8 | 9 | class ReportHandler: 10 | def check_url_info(self, url, device_ip, category): 11 | specific_list = list(filter(lambda x: x.get('url') == url, app_context.merge_list)) 12 | if specific_list and specific_list[0].get('status') == 1: 13 | desc = specific_list[0].get('desc') 14 | count_flag = 1 15 | priority = specific_list[0].get('priority') 16 | else: 17 | desc = 'N/A' 18 | count_flag = -1 19 | priority = -1 20 | info_dict = { 21 | 'coverage':{ 22 | 'url': url, 23 | 'desc': desc, 24 | 'priority': priority, 25 | 'count_flag': count_flag, 26 | 'version_name': app_context.version_name, 27 | 'version_code': app_context.version_code, 28 | 'category': category 29 | } 30 | } 31 | if app_context.info.get(device_ip): 32 | # 如果有Device信息,就上报device相关的信息 33 | info_dict['coverage'].update(app_context.info.get(device_ip)) 34 | return info_dict 35 | 36 | 37 | report_handler = ReportHandler() 38 | 39 | 40 | def report_worker(url, device_ip, category): 41 | update_data = report_handler.check_url_info(url, device_ip, category) 42 | publish('coverage', update_data) 43 | -------------------------------------------------------------------------------- /lyrebird_api_coverage/client/url_compare.py: -------------------------------------------------------------------------------- 1 | from urllib import parse 2 | 3 | 4 | # TODO 校验URL是否相等 需要把{num}情况一起放在这里解决 5 | 6 | def compare_query(url_org, url_compare): 7 | param_org = parse.parse_qs(parse.urlparse(url_org).query, True) 8 | param_compare = parse.parse_qs(parse.urlparse(url_compare).query, True) 9 | for key, value in param_org.items(): 10 | if not (key in param_compare): 11 | return False 12 | if value[0].strip(): 13 | if not param_compare[key] == value: 14 | return False 15 | return True 16 | 17 | -------------------------------------------------------------------------------- /lyrebird_api_coverage/default_conf/base.json: -------------------------------------------------------------------------------- 1 | { 2 | "business": "app_channel", 3 | "version_code": 1, 4 | "version_name": "1.0", 5 | "api_list": [ 6 | { 7 | "desc": "A接口", 8 | "priority": 3, 9 | "url": "meituan.com/test/a" 10 | }, 11 | { 12 | "desc": "B接口", 13 | "priority": 2, 14 | "url": "meituan.com/test/b" 15 | }, 16 | { 17 | "desc": "C接口", 18 | "priority": 2, 19 | "url": "meituan.com/test/c" 20 | }, 21 | { 22 | "desc": "D接口", 23 | "priority": 1, 24 | "url": "meituan.com/test/d" 25 | } 26 | ] 27 | } -------------------------------------------------------------------------------- /lyrebird_api_coverage/default_conf/conf.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /lyrebird_api_coverage/default_conf/filter_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": { 3 | "host": [ 4 | "a.meituan.com", 5 | "b.baidu.com" 6 | ], 7 | "regular": [ 8 | ".webp", 9 | ".gif", 10 | ".jpg", 11 | ".png" 12 | ] 13 | } 14 | } -------------------------------------------------------------------------------- /lyrebird_api_coverage/handlers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meituan-Dianping/lyrebird-api-coverage/d5d37e740e8bafa0d493eced22044f516e7e0937/lyrebird_api_coverage/handlers/__init__.py -------------------------------------------------------------------------------- /lyrebird_api_coverage/handlers/base_source_handler.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import lyrebird 4 | from lyrebird.mock import context 5 | from lyrebird.log import get_logger 6 | 7 | from lyrebird_api_coverage.client.context import app_context 8 | from lyrebird_api_coverage.client.jsonscheme import check_schema, check_url_redundant 9 | from lyrebird_api_coverage.client.load_base import auto_load_base 10 | 11 | CURRENT_DIR = os.path.dirname(__file__) 12 | 13 | PLUGINS_CONF_DIR = lyrebird.get_plugin_storage() 14 | 15 | if not os.path.exists(PLUGINS_CONF_DIR): 16 | os.makedirs(PLUGINS_CONF_DIR) 17 | 18 | # 默认base 文件 19 | DEFAULT_BASE = os.path.join(PLUGINS_CONF_DIR, 'base.json') 20 | 21 | logger = get_logger() 22 | 23 | ''' 24 | Base 处理器 25 | ''' 26 | class BaseDataHandler: 27 | 28 | ''' 29 | 获取base文件 30 | ''' 31 | def get_base_source(self): 32 | json_obj = auto_load_base() 33 | if not json_obj: 34 | json_obj = context.make_fail_response('暂无默认文件,需手动导入base文件') 35 | # 检查不为空的base文件是否符合标准,符合标准check_base返回0 36 | else: 37 | error_response = self.check_base(json_obj) 38 | if error_response: 39 | # 遇到异常就返回 40 | return error_response 41 | return json_obj 42 | 43 | ''' 44 | 检查base是否符合规则 45 | ''' 46 | def check_base(self, obj): 47 | try: 48 | # 检查base schema 49 | if not app_context.is_api_base_data: 50 | check_schema(obj) 51 | # 检查url是否有重复项存在 52 | redundant_items = check_url_redundant(obj) 53 | if redundant_items: 54 | redundant_items_str = '\n'.join(redundant_items) 55 | logger.error( 56 | f'API-Coverage import API file error: Duplicated API\n' 57 | f'{len(redundant_items)} duplicated API:\n' 58 | f'{redundant_items_str}\n' 59 | ) 60 | resp = context.make_fail_response('导入API有重复项' + str(redundant_items)) 61 | lyrebird.publish('api_coverage', 'error', name='import_base') 62 | return resp 63 | # 获取base内容,解析出base的business等字段 64 | filename = f'''{obj.get('business','')}{obj.get('version_name','')}{obj.get('version_code','')}''' 65 | app_context.filename = filename 66 | app_context.business = obj.get('business', '') 67 | app_context.version_name = obj.get('version_name', '--') 68 | app_context.version_code = obj.get('version_code', '--') 69 | return 70 | except Exception as e: 71 | resp = context.make_fail_response(f'导入文件有误: {e}\n请重新import base') 72 | 73 | return resp 74 | -------------------------------------------------------------------------------- /lyrebird_api_coverage/handlers/filter_handler.py: -------------------------------------------------------------------------------- 1 | import codecs 2 | import json 3 | import os 4 | import lyrebird 5 | from lyrebird.mock import context 6 | from lyrebird_api_coverage.client.context import app_context 7 | from lyrebird_api_coverage.client.jsonscheme import check_filter_schema 8 | 9 | CURRENT_DIR = os.path.dirname(__file__) 10 | PLUGIN_DIR = lyrebird.get_plugin_storage() 11 | FILTER_CONF = os.path.join(PLUGIN_DIR, 'filter_conf.json') 12 | 13 | 14 | ''' 15 | 过滤处理器 16 | ''' 17 | 18 | class FilterHandler: 19 | def init_filter_conf(self): 20 | if not os.path.exists(FILTER_CONF): 21 | self.copy_conf_file(FILTER_CONF) 22 | 23 | def copy_conf_file(self, target_path): 24 | os.path.abspath(os.path.join(CURRENT_DIR, '..', './default_conf/filter_config.json')) 25 | f_from = codecs.open(os.path.abspath(os.path.join(CURRENT_DIR, '..', './default_conf/filter_config.json')), 'r', 26 | 'utf-8') 27 | f_to = codecs.open(target_path, 'w', 'utf-8') 28 | f_to.write(f_from.read()) 29 | f_to.close() 30 | f_from.close() 31 | 32 | def get_filer_conf(self): 33 | self.init_filter_conf() 34 | try: 35 | json_obj = json.loads(codecs.open(FILTER_CONF, 'r', 'utf-8').read()) 36 | check_filter_schema(json_obj) 37 | app_context.filter_dic = json_obj 38 | msg = json_obj 39 | except Exception as e: 40 | msg = '过滤请求的配置文件格式有误:' + e.__getattribute__('message') 41 | return msg 42 | 43 | def save_filer_conf(self, conf_obj): 44 | self.init_filter_conf() 45 | try: 46 | check_filter_schema(conf_obj) 47 | f = codecs.open(FILTER_CONF, 'w', 'utf-8') 48 | f.write(json.dumps(conf_obj)) 49 | f.close() 50 | # 配置保存后当时生效 51 | app_context.filter_dic = conf_obj 52 | return context.make_ok_response() 53 | except Exception as e: 54 | msg = '过滤请求的配置文件格式有误:' + e.__getattribute__('message') 55 | lyrebird.publish('api_coverage', 'error', name='set_filter') 56 | return context.make_fail_response(msg) 57 | -------------------------------------------------------------------------------- /lyrebird_api_coverage/handlers/import_file_handler.py: -------------------------------------------------------------------------------- 1 | import codecs 2 | import hashlib 3 | import json 4 | 5 | import lyrebird 6 | from flask import request 7 | from lyrebird_api_coverage.client.load_base import DEFAULT_BASE 8 | 9 | from lyrebird_api_coverage.client.context import app_context 10 | from lyrebird_api_coverage.client.merge_algorithm import mergeAlgorithm 11 | from lyrebird_api_coverage.handlers.base_source_handler import DEFAULT_BASE, BaseDataHandler 12 | 13 | from lyrebird import context 14 | 15 | """ 16 | ImportHandler 17 | 18 | base导入处理器 19 | result导入处理器 20 | 21 | """ 22 | 23 | class ImportHandler: 24 | 25 | def import_base_handler(self): 26 | json_file = request.files.get('json-import') 27 | mimetype = json_file.content_type 28 | # 判断是不是json格式的文件 29 | if mimetype == 'application/json': 30 | # 读取文件流,注意文件流只能read一次 31 | bytes_obj = json_file.read() 32 | try: 33 | check_result = BaseDataHandler().check_base(json.loads(bytes_obj)) 34 | if check_result: 35 | return check_result 36 | 37 | self.write_wb(DEFAULT_BASE, bytes_obj) 38 | # 读取json文件 39 | json_obj = json.loads(codecs.open(DEFAULT_BASE, 'r', 'utf-8').read()) 40 | # 获取文件的sha1 41 | app_context.base_sha1 = self.get_sha1(bytes_obj) 42 | # 初次处理,切换后的result 43 | mergeAlgorithm.first_result_handler(json_obj) 44 | mergeAlgorithm.coverage_arithmetic(json_obj) 45 | resp = context.make_ok_response() 46 | lyrebird.publish('api_coverage', 'operation', name='import_base') 47 | except Exception as e: 48 | resp = context.make_fail_response('导入文件内容格式有误:' + str(e)) 49 | lyrebird.publish('api_coverage', 'error', name='import_base') 50 | else: 51 | resp = context.make_fail_response("Error.The selected non - JSON file.") 52 | lyrebird.publish('api_coverage', 'error', name='import_base') 53 | return resp 54 | 55 | def import_result_handler(self): 56 | json_file = request.files.get('json-import') 57 | mimetype = json_file.content_type 58 | # 读取文件流,注意文件流只能read一次 59 | bytes_obj = json_file.read() 60 | try: 61 | result_obj = json.loads(bytes_obj) 62 | # 获取import文件的sha1 63 | import_sha1 = result_obj.get('base_sha1') 64 | if app_context.base_sha1 == import_sha1: 65 | # merge import result and cache result 66 | # check_result_schema(result_obj) 67 | app_context.coverage = json.loads(bytes_obj).get('coverage') 68 | mergeAlgorithm.merge_resume(result_obj.get('test_data')) 69 | # 放入缓存 70 | # app_context.merge_list = json.loads(bytes_obj).get('test_data') 71 | # app_context.coverage = json.loads(bytes_obj).get('coverage') 72 | resp = context.make_ok_response() 73 | lyrebird.publish('api_coverage', 'operation', name='import_result') 74 | else: 75 | resp = context.make_fail_response('导入的测试结果和之前选择base不匹配') 76 | lyrebird.publish('api_coverage', 'error', name='import_result') 77 | except Exception as e: 78 | resp = context.make_fail_response('导入文件内容格式有误:' + str(e)) 79 | lyrebird.publish('api_coverage', 'error', name='import_result') 80 | return resp 81 | 82 | def write_wb(self, path, obj): 83 | f = codecs.open(path, 'wb') 84 | f.write(obj) 85 | f.close() 86 | 87 | def get_sha1(self, obj): 88 | sha1obj = hashlib.sha1() 89 | sha1obj.update(obj) 90 | hash_sha1 = sha1obj.hexdigest() 91 | return hash_sha1 92 | -------------------------------------------------------------------------------- /lyrebird_api_coverage/handlers/result_handler.py: -------------------------------------------------------------------------------- 1 | import codecs 2 | import json 3 | import os 4 | import time 5 | 6 | import lyrebird 7 | 8 | from lyrebird_api_coverage.client.context import app_context 9 | 10 | from lyrebird_api_coverage.client.merge_algorithm import mergeAlgorithm 11 | 12 | CURRENT_DIR = os.path.dirname(__file__) 13 | 14 | PLUGINS_CONF_DIR = lyrebird.get_plugin_storage() 15 | PLUGINS_DATA_DIR = os.path.join(PLUGINS_CONF_DIR, 'data') 16 | PLUGINS_DUMP_DIR = os.path.join(PLUGINS_CONF_DIR, 'dump') 17 | 18 | if not os.path.exists(PLUGINS_DATA_DIR): 19 | os.makedirs(PLUGINS_DATA_DIR) 20 | 21 | if not os.path.exists(PLUGINS_DUMP_DIR): 22 | os.makedirs(PLUGINS_DUMP_DIR) 23 | 24 | """ 25 | ResultHandler 测试结果处理器 26 | 保存测试结果、续测、清空测试结果 27 | 28 | """ 29 | 30 | class ResultHandler: 31 | 32 | def make_result_dir(self): 33 | save_time = time.strftime('%Y-%m-%d-%H:%M:%S', time.localtime(time.time())) 34 | test_data_dir = os.path.join(PLUGINS_DATA_DIR, str(save_time)) 35 | if not os.path.exists(test_data_dir): 36 | os.makedirs(test_data_dir) 37 | return test_data_dir 38 | 39 | def save_result(self, filename): 40 | # test_data_dir = self.make_result_dir() 41 | test_data_dir = PLUGINS_DATA_DIR 42 | test_path = os.path.join(test_data_dir, str(filename) + '.json') 43 | f = codecs.open(test_path, 'w', 'utf-8') 44 | result_json = json.dumps({'business': app_context.business, 'verison_name': app_context.version_name, 45 | 'verison_code': app_context.version_code, 'file_name': app_context.filename, 46 | 'base_sha1': app_context.base_sha1, 'coverage': app_context.coverage, 47 | 'test_data': app_context.merge_list}, ensure_ascii=False, indent=4) 48 | f.write(result_json) 49 | f.close() 50 | 51 | def dump_info(self, filename): 52 | # test_data_dir = self.make_result_dir() 53 | dump_dir = PLUGINS_DUMP_DIR 54 | test_path = os.path.join(dump_dir, str(filename) + '.json') 55 | f = codecs.open(test_path, 'w', 'utf-8') 56 | result_json = json.dumps({'business': app_context.business, 'verison_name': app_context.version_name, 57 | 'verison_code': app_context.version_code, 'file_name': app_context.filename, 58 | 'base_sha1': app_context.base_sha1, 'coverage': app_context.coverage, 59 | 'test_data': app_context.merge_list}, ensure_ascii=False, indent=4) 60 | f.write(result_json) 61 | f.close() 62 | 63 | def resume_test(self): 64 | test_path = os.path.join(PLUGINS_DATA_DIR, 'result.json') 65 | if not os.path.exists(test_path): 66 | dic = {'status': 'error'} 67 | else: 68 | json_obj = json.loads(codecs.open(test_path, 'r', 'utf-8').read()) 69 | app_context.merge_list = json_obj.get('test_data') 70 | mergeAlgorithm.coverage_handler() 71 | dic = {'status': 'success', 72 | 'message': {'coverage': app_context.coverage, 'test_data': app_context.merge_list}} 73 | return dic 74 | 75 | def clear_cache_result(self): 76 | # 清空 77 | app_context.user_list.clear() 78 | app_context.base_list.clear() 79 | app_context.user_org_list.clear() 80 | # 清空coverage 81 | app_context.coverage.clear() 82 | # 清空缓存list 83 | app_context.merge_list.clear() 84 | # 清空sha1 85 | app_context.base_sha1 = None 86 | -------------------------------------------------------------------------------- /lyrebird_api_coverage/interceptor.py: -------------------------------------------------------------------------------- 1 | import lyrebird 2 | from lyrebird import log, application 3 | from lyrebird_api_coverage.client import format_url 4 | from lyrebird_api_coverage.client.context import app_context 5 | from lyrebird_api_coverage.client.merge_algorithm import mergeAlgorithm 6 | from lyrebird_api_coverage.client.report import report_worker 7 | from lyrebird_api_coverage.client.url_compare import compare_query 8 | from urllib.parse import urlparse 9 | 10 | logger = log.get_logger() 11 | block_list = application.config.get("apicoverage.block_list", []) 12 | import time 13 | 14 | def on_request(msg): 15 | req_starttime = time.time() 16 | req_msg = msg['flow'] 17 | logger.debug(req_msg) 18 | if not msg['flow']['request']['url']: 19 | return 20 | 21 | if msg['flow']['request']['host'] in block_list: 22 | return 23 | 24 | # 获取handler_context.id,为前端展开看详情准备 25 | path_id = msg['flow']['id'] 26 | device_ip = msg['flow']['client_address'] 27 | # 获取产品信息 28 | app_context.category = get_current_category(req_msg) 29 | if app_context.is_api_base_data: 30 | new_path = format_url.format_api(urlparse(msg['flow']['request']['url']).path).lower() 31 | coverage_judgment(new_path, path_id, device_ip, req_starttime, msg, app_context.category) 32 | else: 33 | short_url = msg['flow']['request']['url'].replace('http://', '').replace('https://', '').split('?')[0] 34 | # format之后的真正PATH,处理{num}这样的情况,emit给前端,做刷新table用,同时处理成小写 35 | path = format_url.format_api(short_url).lower() 36 | coverage_judgment(path, path_id, device_ip, req_starttime, msg, app_context.category) 37 | 38 | def coverage_judgment(path, path_id, device_ip, req_starttime, msg, category): 39 | if path in app_context.base_list: 40 | # merge到 context merge list中 41 | mergeAlgorithm.merge_handler_new(path, path_id, category) 42 | # 在base里的需要去计算下覆盖率 43 | mergeAlgorithm.coverage_handler() 44 | # 进行上报 45 | report_worker(path, device_ip, category) 46 | # 计算差值,指定时间间隔内只发1次io msg,限制刷新频率 47 | emit(req_starttime, path) 48 | # 如果path配置了对应的参数 49 | elif path in app_context.path_param_dic: 50 | ulr_list = app_context.path_param_dic[path] 51 | flag = 0 52 | for item in ulr_list: 53 | if compare_query(item['url'], msg['flow']['request']['url']): 54 | mergeAlgorithm.merge_handler_new(item['url_base'], path_id, category) 55 | mergeAlgorithm.coverage_handler() 56 | report_worker(item['url_base'], device_ip, category) 57 | flag = 1 58 | # 如果参数组合不存在,提取关注的字段 59 | if flag == 0: 60 | url_pgroup = '' 61 | params_list = [] 62 | for item in ulr_list: 63 | params_list.extend(item['params'].keys()) 64 | # 去重 65 | for p in list(set(params_list)): 66 | # Todo 这里在初始化之后看一下 67 | val = msg['flow']['request']['query'].get(p) 68 | if url_pgroup: 69 | url_pgroup = url_pgroup + '&' + str(p) + '=' + str(val) 70 | else: 71 | url_pgroup = path + '?' + str(p) + '=' + str(val) 72 | mergeAlgorithm.merge_handler_new(url_pgroup, path_id, category) 73 | mergeAlgorithm.coverage_handler() 74 | report_worker(url_pgroup, device_ip, category) 75 | # 计算差值,指定时间间隔内只发1次io msg,限制刷新频率 76 | emit(req_starttime, path) 77 | # 如果不在base里,不需要merge到数据中 78 | else: 79 | # mergeAlgorithm.merge_handler_new(path, path_id, category) 80 | # 进行上报 81 | report_worker(path, device_ip, category) 82 | 83 | def emit(starttime, path): 84 | duration = starttime - app_context.endtime 85 | if duration > app_context.SOCKET_PUSH_INTERVAL: 86 | app_context.endtime = starttime 87 | lyrebird.emit('apiCoverageBaseData') 88 | 89 | # 获取产品信息 90 | def get_current_category(req_msg): 91 | # 读取产品映射关系 92 | if application.config.get('apicoverage.category'): 93 | for item in application.config.get('apicoverage.category'): 94 | params = req_msg.get('request').get(item.get('params_source')) 95 | new_params = {k.lower(): v for k, v in params.items()} 96 | if item.get('keyWord') != '' and params.get(item['params']) and params.get(item['params']).startswith(item.get('keyWord')): 97 | return item.get('categoryName') 98 | elif item.get('keyWord') == '' and item.get('params') in new_params: 99 | return new_params.get(item.get('params').lower()) 100 | 101 | return '' 102 | -------------------------------------------------------------------------------- /lyrebird_api_coverage/manifest.py: -------------------------------------------------------------------------------- 1 | from lyrebird.plugins import manifest 2 | from . import api 3 | from . import interceptor 4 | from flask import Response 5 | from lyrebird_api_coverage.handlers.base_source_handler import BaseDataHandler 6 | from lyrebird_api_coverage.client.merge_algorithm import mergeAlgorithm 7 | from lyrebird_api_coverage.client.event_subscibe import event_subscribe 8 | 9 | # 执行插件初始化操作 10 | # 获取base_data_config文件信息 11 | base_dict = BaseDataHandler().get_base_source() 12 | # 如果import的文件异常 13 | if not isinstance(base_dict, Response): 14 | mergeAlgorithm.first_result_handler(base_dict) 15 | mergeAlgorithm.coverage_arithmetic(base_dict) 16 | # 总线消息订阅 17 | event_subscribe() 18 | 19 | manifest( 20 | id='api_coverage', 21 | name='APICoverage', 22 | icon='mdi-google-analytics', 23 | api=[ 24 | # 获取内存里保存的测试结果API 25 | ('/api/getTest', api.get_test_data), 26 | # 获取内存里保存的测试覆盖率信息 27 | ('/api/getCoverage', api.get_coverage), 28 | # 保存测试数据在本地 29 | ('/api/saveResult', api.save_result,['POST']), 30 | # 续传测试结果 31 | ('/api/resumeTest', api.resume_test, ['POST']), 32 | # 清空测试缓存结果 33 | ('/api/clearResult', api.clear_result), 34 | # 导入base json文件 35 | ('/api/importBase', api.import_base, ['POST']), 36 | # 获取filter的conf文件 37 | ('/api/getFilterConf', api.get_filter_conf), 38 | # 覆盖配置filter conf文件 39 | ('/api/setFilterConf', api.set_filter_conf, ['POST']), 40 | # overbridge dump 信息用的API 41 | ('/api/dump', api.dump), 42 | # baseInfo 43 | ('/api/baseInfo', api.get_base_info), 44 | ], 45 | background=[ 46 | ], 47 | event=[ 48 | ('flow', interceptor.on_request) 49 | ] 50 | ) 51 | -------------------------------------------------------------------------------- /lyrebird_api_coverage/version.py: -------------------------------------------------------------------------------- 1 | IVERSION = (0, 4, 1) 2 | VERSION = ".".join(str(i) for i in IVERSION) 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -e .[dev] 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup,find_packages 2 | import os 3 | import runpy 4 | 5 | here = os.path.abspath(os.path.dirname(__file__)) 6 | 7 | VERSION = runpy.run_path( 8 | os.path.join(here, 'lyrebird_api_coverage', 'version.py') 9 | )['VERSION'] 10 | 11 | with open(os.path.join(here, 'README.md'), encoding='utf-8') as f: 12 | long_description = f.read() 13 | 14 | setup( 15 | name='lyrebird-api-coverage', 16 | version=VERSION, 17 | packages=find_packages(), 18 | url='https://github.com/meituan/lyrebird-api-coverage', 19 | author='HBQA', 20 | long_description=long_description, 21 | long_description_content_type="text/markdown", 22 | include_package_data=True, 23 | zip_safe=False, 24 | classifiers=( 25 | "Programming Language :: Python :: 3 :: Only", 26 | "Programming Language :: Python :: 3.7", 27 | "License :: OSI Approved :: MIT License", 28 | "Operating System :: MacOS", 29 | ), 30 | entry_points={ 31 | 'lyrebird_plugin': [ 32 | 'lyrebird_api_coverage = lyrebird_api_coverage.manifest' 33 | ] 34 | }, 35 | install_requires=[ 36 | 'lyrebird>=2.10.3', 37 | 'jsonschema' 38 | ], 39 | extras_require={ 40 | 'dev': [ 41 | "autopep8", 42 | "pylint", 43 | "pytest" 44 | ] 45 | } 46 | ) 47 | -------------------------------------------------------------------------------- /tests/test_verify_parameter_name.py: -------------------------------------------------------------------------------- 1 | from lyrebird_api_coverage.client.url_compare import compare_query 2 | 3 | 4 | base_url1 = "abc.test.com/test?a=2&b=&c=" 5 | base_url2 = "abc.test.com/test?a=&c=&b=" 6 | base_url3 = "abc.test.com/test?a=2&b=c&c=" 7 | base_url4 = "abc.test.com/test?a=2&b=3&c=e" 8 | base_url5 = "abc.test.com/test?a=4&b=" 9 | req_url_1 = "http://abc.test.com/test?a=2&c=e&b=d" 10 | req_url_2 = "http://abc.test.com/test?a=2&b=d&c=e" 11 | req_url_3 = "http://abc.test.com/test?a=2&b=c" 12 | req_url_4 = "http://abc.test.com/test?a=2&b=3&c=" 13 | req_url_5 = "http://abc.test.com/test?a=4&b=&c=e&f=q" 14 | 15 | 16 | def test_compare(): 17 | result1 = compare_query(base_url1, req_url_1) 18 | assert result1 19 | 20 | result2 = compare_query(base_url2, req_url_2) 21 | assert result2 22 | 23 | result3 = compare_query(base_url3, req_url_3) 24 | assert not result3 25 | 26 | result4 = compare_query(base_url4, req_url_4) 27 | assert not result4 28 | 29 | result5 = compare_query(base_url5, req_url_5) 30 | assert result5 31 | --------------------------------------------------------------------------------
Coverage
Info