diff --git a/.github/workflows/auto-build-rpm.yml b/.github/workflows/auto-build-rpm.yml index 4c26bd98cb..16b4d813fc 100644 --- a/.github/workflows/auto-build-rpm.yml +++ b/.github/workflows/auto-build-rpm.yml @@ -27,7 +27,7 @@ jobs: steps: - name: Check out code - uses: actions/checkout@v2 + uses: actions/checkout@v3 - uses: docker/setup-buildx-action@v1 @@ -80,7 +80,7 @@ jobs: - name: Publish Artifact if: ${{ startsWith(github.ref, 'refs/heads/release/') }} - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: "rpm" path: "./apisix-build-tools/output/apisix-dashboard-${{ steps.branch_env.outputs.version }}-0.el7.x86_64.rpm" diff --git a/.github/workflows/backend-cli-test.yml b/.github/workflows/backend-cli-test.yml index 970e165cea..b6b7b6f7de 100644 --- a/.github/workflows/backend-cli-test.yml +++ b/.github/workflows/backend-cli-test.yml @@ -28,7 +28,7 @@ jobs: ALLOW_NONE_AUTHENTICATION: yes steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 with: submodules: recursive diff --git a/.github/workflows/backend-e2e-test.yml b/.github/workflows/backend-e2e-test.yml index 37e68aacb1..a0977e342d 100644 --- a/.github/workflows/backend-e2e-test.yml +++ b/.github/workflows/backend-e2e-test.yml @@ -21,10 +21,10 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: setup go - uses: actions/setup-go@v2.1.5 + uses: actions/setup-go@v3 with: go-version: "1.15" submodules: recursive @@ -112,10 +112,10 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: setup go - uses: actions/setup-go@v2.1.5 + uses: actions/setup-go@v3 with: go-version: "1.17" diff --git a/.github/workflows/backend-unit-test.yml b/.github/workflows/backend-unit-test.yml index 992e38d3e4..a582231d3e 100644 --- a/.github/workflows/backend-unit-test.yml +++ b/.github/workflows/backend-unit-test.yml @@ -26,10 +26,10 @@ jobs: ALLOW_NONE_AUTHENTICATION: yes steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: setup go - uses: actions/setup-go@v2.1.5 + uses: actions/setup-go@v3 with: go-version: "1.15" diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index a1b9ca3de5..126d05dd4c 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -40,7 +40,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/deploy-with-docker.yml b/.github/workflows/deploy-with-docker.yml index e19bf60c4a..9da71f4bd1 100644 --- a/.github/workflows/deploy-with-docker.yml +++ b/.github/workflows/deploy-with-docker.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 with: submodules: recursive diff --git a/.github/workflows/frontend-e2e-test.yml b/.github/workflows/frontend-e2e-test.yml index b5dc4dcb16..218842f888 100644 --- a/.github/workflows/frontend-e2e-test.yml +++ b/.github/workflows/frontend-e2e-test.yml @@ -37,19 +37,19 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: submodules: recursive - name: Setup Node.js environment - uses: actions/setup-node@v2.5.1 + uses: actions/setup-node@v3 with: node-version: 14.x cache: 'yarn' cache-dependency-path: web/yarn.lock - name: Setup golang environment - uses: actions/setup-go@v2.1.5 + uses: actions/setup-go@v3 with: go-version: '1.15' @@ -93,7 +93,7 @@ jobs: bash <(curl -s https://codecov.io/bash) -f ./coverage/coverage-final.json -F frontend-e2e-test - name: Archive code coverage results - uses: actions/upload-artifact@v2.3.0 + uses: actions/upload-artifact@v3 if: always() with: name: cypress-report diff --git a/.github/workflows/frontend-plugin-e2e-test.yml b/.github/workflows/frontend-plugin-e2e-test.yml index 0cf61f539d..08a818d2f4 100644 --- a/.github/workflows/frontend-plugin-e2e-test.yml +++ b/.github/workflows/frontend-plugin-e2e-test.yml @@ -35,19 +35,19 @@ jobs: ALLOW_NONE_AUTHENTICATION: yes steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: submodules: recursive - name: Setup Node.js environment - uses: actions/setup-node@v2.5.1 + uses: actions/setup-node@v3 with: node-version: 14.x cache: 'yarn' cache-dependency-path: web/yarn.lock - name: Setup golang environment - uses: actions/setup-go@v2.1.5 + uses: actions/setup-go@v3 with: go-version: '1.15' @@ -86,7 +86,7 @@ jobs: run: yarn test-plugin:e2e - name: Archive code coverage results - uses: actions/upload-artifact@v2.3.0 + uses: actions/upload-artifact@v3 if: always() with: name: cypress-report diff --git a/.github/workflows/gitleaks.yml b/.github/workflows/gitleaks.yml index 03328399bd..5c4249f3d4 100644 --- a/.github/workflows/gitleaks.yml +++ b/.github/workflows/gitleaks.yml @@ -18,7 +18,7 @@ jobs: gitleaks: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 with: fetch-depth: '1' submodules: recursive diff --git a/.github/workflows/go-lint.yml b/.github/workflows/go-lint.yml index b72e9b953a..c78940cc35 100644 --- a/.github/workflows/go-lint.yml +++ b/.github/workflows/go-lint.yml @@ -17,7 +17,7 @@ jobs: needs: go-filter if: needs.go-filter.outputs.matches == 'true' steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: golangci-lint uses: golangci/golangci-lint-action@v2 @@ -29,9 +29,9 @@ jobs: gofmt: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: setup go - uses: actions/setup-go@v2.1.5 + uses: actions/setup-go@v3 with: go-version: '1.15' diff --git a/.github/workflows/license-checker.yml b/.github/workflows/license-checker.yml index 09b2cbaac1..87e53281cd 100644 --- a/.github/workflows/license-checker.yml +++ b/.github/workflows/license-checker.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: run license check run: | diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index e153c27750..413bb73f36 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -7,9 +7,9 @@ jobs: name: 🍇 Markdown runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: 🚀 Use Node.js - uses: actions/setup-node@v2.5.1 + uses: actions/setup-node@v3 with: node-version: '14.x' - run: npm install -g markdownlint-cli@0.25.0 @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code. - uses: actions/checkout@v1 + uses: actions/checkout@v3 - name: Install run: | wget -O - -q https://git.io/misspell | sh -s -- -b . @@ -33,8 +33,8 @@ jobs: name: 🍏 YAML runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2.3.2 + - uses: actions/checkout@v3 + - uses: actions/setup-python@v3.1.1 with: python-version: '3.x' # Version range or exact version of a Python version to use, using SemVer's version range syntax architecture: 'x64' # optional x64 or x86. Defaults to x64 if not specified @@ -50,6 +50,6 @@ jobs: name: Trailing whitespace runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Check for trailing whitespace run: "! git grep -EIn $'[ \t]+$'" diff --git a/.github/workflows/make-build.yaml b/.github/workflows/make-build.yaml index b2e0ca8c9c..15730fdb20 100644 --- a/.github/workflows/make-build.yaml +++ b/.github/workflows/make-build.yaml @@ -34,19 +34,19 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: submodules: recursive - name: Setup Node.js environment - uses: actions/setup-node@v2.5.1 + uses: actions/setup-node@v3 with: node-version: 14.x cache: 'yarn' cache-dependency-path: web/yarn.lock - name: Setup golang environment - uses: actions/setup-go@v2.1.5 + uses: actions/setup-go@v3 with: go-version: '1.15' diff --git a/.github/workflows/release-test.yml b/.github/workflows/release-test.yml index 28a6bf2327..3913004a4e 100644 --- a/.github/workflows/release-test.yml +++ b/.github/workflows/release-test.yml @@ -40,10 +40,10 @@ jobs: ALLOW_NONE_AUTHENTICATION: yes steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: setup go - uses: actions/setup-go@v2.1.5 + uses: actions/setup-go@v3 with: go-version: '1.15' diff --git a/.github/workflows/test-frontend-multiple-node-build.yml b/.github/workflows/test-frontend-multiple-node-build.yml index 77055114ad..0836519069 100644 --- a/.github/workflows/test-frontend-multiple-node-build.yml +++ b/.github/workflows/test-frontend-multiple-node-build.yml @@ -30,11 +30,11 @@ jobs: # Steps represent a sequence of tasks that will be executed as part of the job steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 with: submodules: recursive - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v2.5.1 + uses: actions/setup-node@v3 with: node-version: ${{ matrix.node-version }} cache: 'yarn' diff --git a/CHANGELOG.md b/CHANGELOG.md index c98081a242..7222455cc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,28 +44,28 @@ This release contains some features and bugfixes, and all the existing functiona ### Core -feat: add data loader framework [#2371](https://github.com/apache/apisix-dashboard/pull/2371) -feat: support protobuf on Web [#2320](https://github.com/apache/apisix-dashboard/pull/2320) -feat: basic support Apache APISIX 2.12.1 [#2315](https://github.com/apache/apisix-dashboard/pull/2315) -feat: Add more fields to limit-count plugin [#2322](https://github.com/apache/apisix-dashboard/pull/2322) -feat: support APISIX_PROFILE for env-specific configuration [#2293](https://github.com/apache/apisix-dashboard/pull/2293) -feat(upstream): add upstream priority field [#2271](https://github.com/apache/apisix-dashboard/pull/2271) -feat(route): show route id in list and edit views [#2269](https://github.com/apache/apisix-dashboard/pull/2269) -feat(route): remove key-auth plugin tip [#2261](https://github.com/apache/apisix-dashboard/pull/2261) -feat: add rejected_msg field to limit-count plugin form [#2328](https://github.com/apache/apisix-dashboard/pull/2328) -feat: add rejected_msg field to limit-req plugin form [#2312](https://github.com/apache/apisix-dashboard/pull/2312) -chore: Create route form optimization [#2336](https://github.com/apache/apisix-dashboard/pull/2336) -chore: add loading in route page [#2287](https://github.com/apache/apisix-dashboard/pull/2287) -chore(upstream): remove default port for upstream health check [#2278](https://github.com/apache/apisix-dashboard/pull/2278) -style: add ellipsis for route table [#2317](https://github.com/apache/apisix-dashboard/pull/2317) +- feat: add data loader framework [#2371](https://github.com/apache/apisix-dashboard/pull/2371) +- feat: support protobuf on Web [#2320](https://github.com/apache/apisix-dashboard/pull/2320) +- feat: basic support Apache APISIX 2.12.1 [#2315](https://github.com/apache/apisix-dashboard/pull/2315) +- feat: Add more fields to limit-count plugin [#2322](https://github.com/apache/apisix-dashboard/pull/2322) +- feat: support APISIX_PROFILE for env-specific configuration [#2293](https://github.com/apache/apisix-dashboard/pull/2293) +- feat(upstream): add upstream priority field [#2271](https://github.com/apache/apisix-dashboard/pull/2271) +- feat(route): show route id in list and edit views [#2269](https://github.com/apache/apisix-dashboard/pull/2269) +- feat(route): remove key-auth plugin tip [#2261](https://github.com/apache/apisix-dashboard/pull/2261) +- feat: add rejected_msg field to limit-count plugin form [#2328](https://github.com/apache/apisix-dashboard/pull/2328) +- feat: add rejected_msg field to limit-req plugin form [#2312](https://github.com/apache/apisix-dashboard/pull/2312) +- chore: Create route form optimization [#2336](https://github.com/apache/apisix-dashboard/pull/2336) +- chore: add loading in route page [#2287](https://github.com/apache/apisix-dashboard/pull/2287) +- chore(upstream): remove default port for upstream health check [#2278](https://github.com/apache/apisix-dashboard/pull/2278) +- style: add ellipsis for route table [#2317](https://github.com/apache/apisix-dashboard/pull/2317) ### Bugfix -fix: ! (reverse) operator not handled correctly [#2364](https://github.com/apache/apisix-dashboard/pull/2364) -fix: idle_timeout filed to support set zero value [#2296](https://github.com/apache/apisix-dashboard/pull/2296) -fix: retries field to support zero value [#2298](https://github.com/apache/apisix-dashboard/pull/2298) -fix: get current dir error [#2283](https://github.com/apache/apisix-dashboard/pull/2283) -style: remove extra margin value [#2300](https://github.com/apache/apisix-dashboard/pull/2300) +- fix: ! (reverse) operator not handled correctly [#2364](https://github.com/apache/apisix-dashboard/pull/2364) +- fix: idle_timeout filed to support set zero value [#2296](https://github.com/apache/apisix-dashboard/pull/2296) +- fix: retries field to support zero value [#2298](https://github.com/apache/apisix-dashboard/pull/2298) +- fix: get current dir error [#2283](https://github.com/apache/apisix-dashboard/pull/2283) +- style: remove extra margin value [#2300](https://github.com/apache/apisix-dashboard/pull/2300) # 2.10.1 diff --git a/README.md b/README.md index 71975b9c01..99fcca698f 100644 --- a/README.md +++ b/README.md @@ -46,8 +46,9 @@ Note: Currently the Dashboard does not have complete coverage of Apache APISIX f ## Demo +[Online Playground](http://20.210.250.99:9000/) + ```text -URL: http://106.55.144.26/ Username: admin Password: admin ``` diff --git a/api/build.sh b/api/build.sh index 4df783f49a..db74cb20c1 100755 --- a/api/build.sh +++ b/api/build.sh @@ -45,6 +45,7 @@ fi cd ./api && go build -o ../output/manager-api -ldflags "${GOLDFLAGS}" ./main.go && cd .. cp ./api/conf/schema.json ./output/conf/schema.json +cp ./api/conf/customize_schema.json ./output/conf/customize_schema.json cp ./api/conf/conf*.yaml ./output/conf/ echo "Build the Manager API successfully" diff --git a/api/conf/conf.yaml b/api/conf/conf.yaml index 30901946a5..84e1f3aba7 100644 --- a/api/conf/conf.yaml +++ b/api/conf/conf.yaml @@ -60,6 +60,14 @@ conf: # such as absolute path on Windows: winfile:///C:\access.log # log example: 2020-12-09T16:38:09.039+0800 INFO filter/logging.go:46 /apisix/admin/routes/r1 {"status": 401, "host": "127.0.0.1:9000", "query": "asdfsafd=adf&a=a", "requestId": "3d50ecb8-758c-46d1-af5b-cd9d1c820156", "latency": 0, "remoteIP": "127.0.0.1", "method": "PUT", "errs": []} max_cpu: 0 # supports tweaking with the number of OS threads are going to be used for parallelism. Default value: 0 [will use max number of available cpu cores considering hyperthreading (if any)]. If the value is negative, is will not touch the existing parallelism profile. + # security: + # access_control_allow_origin: "http://httpbin.org" + # access_control_allow_credentials: true # support using custom cors configration + # access_control_allow_headers: "Authorization" + # access_control-allow_methods: "*" + # x_frame_options: "deny" + # content_security_policy: ""default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'"" + authentication: secret: diff --git a/api/conf/customize_schema.json b/api/conf/customize_schema.json new file mode 100644 index 0000000000..bde2563183 --- /dev/null +++ b/api/conf/customize_schema.json @@ -0,0 +1,33 @@ +{ + "main": { + "system_config": { + "properties": { + "config_name": { + "maxLength":100, + "minLength":1, + "pattern":"^[a-zA-Z0-9_]+$", + "type":"string" + }, + "desc": { + "maxLength":256, + "type":"string" + }, + "payload": { + "type":"object", + "minProperties":1 + }, + "create_time": { + "type":"integer" + }, + "update_time": { + "type":"integer" + } + }, + "required": [ + "config_name", + "payload" + ], + "type":"object" + } + } +} diff --git a/api/conf/schema.json b/api/conf/schema.json index a65c22ac05..fd88252be6 100644 --- a/api/conf/schema.json +++ b/api/conf/schema.json @@ -11,7 +11,6 @@ }, "labels": { "description": "key/value pairs to specify attributes", - "maxProperties": 16, "patternProperties": { ".*": { "description": "value of label", @@ -97,7 +96,6 @@ }, "labels": { "description": "key/value pairs to specify attributes", - "maxProperties": 16, "patternProperties": { ".*": { "description": "value of label", @@ -378,7 +376,6 @@ }, "labels": { "description": "key/value pairs to specify attributes", - "maxProperties": 16, "patternProperties": { ".*": { "description": "value of label", @@ -931,7 +928,6 @@ }, "labels": { "description": "key/value pairs to specify attributes", - "maxProperties": 16, "patternProperties": { ".*": { "description": "value of label", @@ -1161,7 +1157,6 @@ }, "labels": { "description": "key/value pairs to specify attributes", - "maxProperties": 16, "patternProperties": { ".*": { "description": "value of label", @@ -1555,7 +1550,6 @@ }, "labels": { "description": "key/value pairs to specify attributes", - "maxProperties": 16, "patternProperties": { ".*": { "description": "value of label", @@ -1815,7 +1809,6 @@ }, "labels": { "description": "key/value pairs to specify attributes", - "maxProperties": 16, "patternProperties": { ".*": { "description": "value of label", @@ -2316,7 +2309,6 @@ }, "labels": { "description": "key/value pairs to specify attributes", - "maxProperties": 16, "patternProperties": { ".*": { "description": "value of label", @@ -2857,7 +2849,6 @@ }, "labels": { "description": "key/value pairs to specify attributes", - "maxProperties": 16, "patternProperties": { ".*": { "description": "value of label", @@ -3237,6 +3228,21 @@ } ], "properties": { + "access_denied_redirect_uri": { + "maxLength": 2048, + "minLength": 1, + "type": "string" + }, + "access_token_expires_in": { + "default": 300, + "minimum": 1, + "type": "integer" + }, + "access_token_expires_leeway": { + "default": 0, + "minimum": 0, + "type": "integer" + }, "audience": { "description": "Deprecated, use `client_id` instead.", "maxLength": 100, @@ -3315,6 +3321,16 @@ ], "type": "string" }, + "refresh_token_expires_in": { + "default": 3600, + "minimum": 1, + "type": "integer" + }, + "refresh_token_expires_leeway": { + "default": 0, + "minimum": 0, + "type": "integer" + }, "resource_registration_endpoint": { "maxLength": 4096, "minLength": 1, @@ -3542,6 +3558,97 @@ "scope": "global", "version": 0.1 }, + "clickhouse-logger": { + "metadata_schema": { + "properties": { + "log_format": { + "default": { + "@timestamp": "$time_iso8601", + "client_ip": "$remote_addr", + "host": "$host" + }, + "type": "object" + } + }, + "type": "object" + }, + "priority": 398, + "schema": { + "$comment": "this is a mark for our injected plugin schema", + "properties": { + "batch_max_size": { + "default": 1000, + "minimum": 1, + "type": "integer" + }, + "buffer_duration": { + "default": 60, + "minimum": 1, + "type": "integer" + }, + "database": { + "default": "", + "type": "string" + }, + "disable": { + "type": "boolean" + }, + "endpoint_addr": { + "pattern": "^[^\\/]+:\\/\\/([\\da-zA-Z.-]+|\\[[\\da-fA-F:]+\\])(:\\d+)?", + "type": "string" + }, + "inactive_timeout": { + "default": 5, + "minimum": 1, + "type": "integer" + }, + "logtable": { + "default": "", + "type": "string" + }, + "max_retry_count": { + "default": 0, + "minimum": 0, + "type": "integer" + }, + "name": { + "default": "clickhouse-logger", + "type": "string" + }, + "password": { + "default": "", + "type": "string" + }, + "retry_delay": { + "default": 1, + "minimum": 0, + "type": "integer" + }, + "ssl_verify": { + "default": true, + "type": "boolean" + }, + "timeout": { + "default": 3, + "minimum": 1, + "type": "integer" + }, + "user": { + "default": "", + "type": "string" + } + }, + "required": [ + "database", + "endpoint_addr", + "logtable", + "password", + "user" + ], + "type": "object" + }, + "version": 0.1 + }, "client-control": { "priority": 22000, "schema": { @@ -3651,6 +3758,18 @@ "version": 0.1 }, "cors": { + "metadata_schema": { + "properties": { + "allow_origins": { + "additionalProperties": { + "pattern": "^(\\*|\\*\\*|null|\\w+://[^,]+(,\\w+://[^,]+)*)$", + "type": "string" + }, + "type": "object" + } + }, + "type": "object" + }, "priority": 4000, "schema": { "$comment": "this is a mark for our injected plugin schema", @@ -3676,6 +3795,17 @@ "pattern": "^(\\*|\\*\\*|null|\\w+://[^,]+(,\\w+://[^,]+)*)$", "type": "string" }, + "allow_origins_by_metadata": { + "description": "set allowed origins by referencing origins in plugin metadata", + "items": { + "maxLength": 4096, + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, "allow_origins_by_regex": { "description": "you can use regex to allow specific origins when no credentials,for example use [.*\\.test.com] to allow a.test.com and b.test.com", "items": { @@ -3705,6 +3835,36 @@ }, "version": 0.1 }, + "csrf": { + "priority": 2980, + "schema": { + "$comment": "this is a mark for our injected plugin schema", + "properties": { + "disable": { + "type": "boolean" + }, + "expires": { + "default": 7200, + "description": "expires time(s) for csrf token", + "type": "integer" + }, + "key": { + "description": "use to generate csrf token", + "type": "string" + }, + "name": { + "default": "apisix-csrf-token", + "description": "the csrf token name", + "type": "string" + } + }, + "required": [ + "key" + ], + "type": "object" + }, + "version": 0.1 + }, "datadog": { "metadata_schema": { "properties": { @@ -3868,6 +4028,11 @@ "tcp" ] }, + { + "required": [ + "clickhouse" + ] + }, { "required": [ "host", @@ -3886,6 +4051,41 @@ "minimum": 1, "type": "integer" }, + "clickhouse": { + "properties": { + "database": { + "default": "", + "type": "string" + }, + "endpoint_addr": { + "1": { + "pattern": "^[^\\/]+:\\/\\/([\\da-zA-Z.-]+|\\[[\\da-fA-F:]+\\])(:\\d+)?", + "type": "string" + }, + "default": "http://127.0.0.1:8123" + }, + "logtable": { + "default": "", + "type": "string" + }, + "password": { + "default": "", + "type": "string" + }, + "user": { + "default": "default", + "type": "string" + } + }, + "required": [ + "database", + "endpoint_addr", + "logtable", + "password", + "user" + ], + "type": "object" + }, "host": { "1": { "pattern": "^\\*?[0-9a-zA-Z-._]+$", @@ -4199,6 +4399,38 @@ }, "version": 0.1 }, + "file-logger": { + "metadata_schema": { + "properties": { + "log_format": { + "default": { + "@timestamp": "$time_iso8601", + "client_ip": "$remote_addr", + "host": "$host" + }, + "type": "object" + } + }, + "type": "object" + }, + "priority": 399, + "schema": { + "$comment": "this is a mark for our injected plugin schema", + "properties": { + "disable": { + "type": "boolean" + }, + "path": { + "type": "string" + } + }, + "required": [ + "path" + ], + "type": "object" + }, + "version": 0.1 + }, "forward-auth": { "priority": 2002, "schema": { @@ -4717,6 +4949,10 @@ "minimum": 0, "type": "integer" }, + "ssl_verify": { + "default": false, + "type": "boolean" + }, "timeout": { "default": 3, "minimum": 1, @@ -4914,8 +5150,20 @@ "schema": { "$comment": "this is a mark for our injected plugin schema", "properties": { + "cookie": { + "default": "jwt", + "type": "string" + }, "disable": { "type": "boolean" + }, + "header": { + "default": "authorization", + "type": "string" + }, + "query": { + "default": "jwt", + "type": "string" } }, "type": "object" @@ -5197,90 +5445,55 @@ "priority": 1002, "schema": { "$comment": "this is a mark for our injected plugin schema", - "dependencies": { - "policy": { - "oneOf": [ - { - "properties": { - "policy": { - "enum": [ - "local" - ] - } - } + "else": { + "if": { + "properties": { + "policy": { + "enum": [ + "redis-cluster" + ] + } + } + }, + "then": { + "properties": { + "redis_cluster_name": { + "type": "string" }, - { - "properties": { - "policy": { - "enum": [ - "redis" - ] - }, - "redis_database": { - "default": 0, - "minimum": 0, - "type": "integer" - }, - "redis_host": { - "minLength": 2, - "type": "string" - }, - "redis_password": { - "minLength": 0, - "type": "string" - }, - "redis_port": { - "default": 6379, - "minimum": 1, - "type": "integer" - }, - "redis_timeout": { - "default": 1000, - "minimum": 1, - "type": "integer" - } + "redis_cluster_nodes": { + "items": { + "maxLength": 100, + "minLength": 2, + "type": "string" }, - "required": [ - "redis_host" - ] + "minItems": 2, + "type": "array" }, - { - "properties": { - "policy": { - "enum": [ - "redis-cluster" - ] - }, - "redis_cluster_name": { - "type": "string" - }, - "redis_cluster_nodes": { - "items": { - "maxLength": 100, - "minLength": 2, - "type": "string" - }, - "minItems": 2, - "type": "array" - }, - "redis_password": { - "minLength": 0, - "type": "string" - }, - "redis_timeout": { - "default": 1000, - "minimum": 1, - "type": "integer" - } - }, - "required": [ - "redis_cluster_name", - "redis_cluster_nodes" - ] + "redis_password": { + "minLength": 0, + "type": "string" + }, + "redis_timeout": { + "default": 1000, + "minimum": 1, + "type": "integer" } + }, + "required": [ + "redis_cluster_name", + "redis_cluster_nodes" ] } }, + "if": { + "properties": { + "policy": { + "enum": [ + "redis" + ] + } + } + }, "properties": { "allow_degradation": { "default": false, @@ -5341,6 +5554,36 @@ "count", "time_window" ], + "then": { + "properties": { + "redis_database": { + "default": 0, + "minimum": 0, + "type": "integer" + }, + "redis_host": { + "minLength": 2, + "type": "string" + }, + "redis_password": { + "minLength": 0, + "type": "string" + }, + "redis_port": { + "default": 6379, + "minimum": 1, + "type": "integer" + }, + "redis_timeout": { + "default": 1000, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "redis_host" + ] + }, "type": "object" }, "version": 0.4 @@ -5414,6 +5657,214 @@ "scope": "global", "version": 0.1 }, + "loggly": { + "metadata_schema": { + "properties": { + "host": { + "default": "logs-01.loggly.com", + "type": "string" + }, + "log_format": { + "type": "object" + }, + "port": { + "default": 514, + "type": "integer" + }, + "protocol": { + "default": "syslog", + "enum": [ + "http", + "https", + "syslog" + ], + "type": "string" + }, + "timeout": { + "default": 5000, + "minimum": 1, + "type": "integer" + } + }, + "type": "object" + }, + "priority": 411, + "schema": { + "$comment": "this is a mark for our injected plugin schema", + "properties": { + "batch_max_size": { + "default": 1000, + "minimum": 1, + "type": "integer" + }, + "buffer_duration": { + "default": 60, + "minimum": 1, + "type": "integer" + }, + "customer_token": { + "type": "string" + }, + "disable": { + "type": "boolean" + }, + "inactive_timeout": { + "default": 5, + "minimum": 1, + "type": "integer" + }, + "include_req_body": { + "default": false, + "type": "boolean" + }, + "include_resp_body": { + "default": false, + "type": "boolean" + }, + "include_resp_body_expr": { + "items": { + "type": "array" + }, + "minItems": 1, + "type": "array" + }, + "max_retry_count": { + "default": 0, + "minimum": 0, + "type": "integer" + }, + "name": { + "default": "loggly", + "type": "string" + }, + "retry_delay": { + "default": 1, + "minimum": 0, + "type": "integer" + }, + "severity": { + "default": "INFO", + "description": "base severity log level", + "enum": [ + "ALERT", + "CRIT", + "DEBUG", + "EMEGR", + "ERR", + "INFO", + "NOTICE", + "WARNING", + "alert", + "crit", + "debug", + "emegr", + "err", + "info", + "notice", + "warning" + ], + "type": "string" + }, + "severity_map": { + "additionalProperties": false, + "description": "upstream response code vs syslog severity mapping", + "patternProperties": { + "^[1-5][0-9]{2}$": { + "description": "keys are HTTP status code, values are severity", + "enum": [ + "ALERT", + "CRIT", + "DEBUG", + "EMEGR", + "ERR", + "INFO", + "NOTICE", + "WARNING", + "alert", + "crit", + "debug", + "emegr", + "err", + "info", + "notice", + "warning" + ], + "type": "string" + } + }, + "type": "object" + }, + "ssl_verify": { + "default": true, + "type": "boolean" + }, + "tags": { + "default": [ + "apisix" + ], + "items": { + "pattern": "^(?!tag=)[ -~]*", + "type": "string" + }, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "customer_token" + ], + "type": "object" + }, + "version": 0.1 + }, + "mocking": { + "priority": 10900, + "schema": { + "$comment": "this is a mark for our injected plugin schema", + "anyOf": [ + { + "required": [ + "response_example" + ] + }, + { + "required": [ + "response_schema" + ] + } + ], + "properties": { + "content_type": { + "default": "application/json;charset=utf8", + "type": "string" + }, + "delay": { + "default": 0, + "type": "integer" + }, + "disable": { + "type": "boolean" + }, + "response_example": { + "type": "string" + }, + "response_schema": { + "type": "object" + }, + "response_status": { + "default": 200, + "minimum": 100, + "type": "integer" + }, + "with_mock_header": { + "default": true, + "type": "boolean" + } + }, + "type": "object" + }, + "version": 0.1 + }, "node-status": { "priority": 1000, "schema": { @@ -5525,6 +5976,10 @@ "default": "/logout", "type": "string" }, + "post_logout_redirect_uri": { + "description": "the URI will be redirect when request logout_path", + "type": "string" + }, "public_key": { "type": "string" }, @@ -5578,6 +6033,102 @@ }, "version": 0.1 }, + "opentelemetry": { + "priority": 12009, + "schema": { + "$comment": "this is a mark for our injected plugin schema", + "properties": { + "additional_attributes": { + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + "disable": { + "type": "boolean" + }, + "sampler": { + "default": { + "name": "always_off", + "options": { + "fraction": 0, + "root": { + "name": "always_off" + } + } + }, + "properties": { + "name": { + "default": "always_off", + "enum": [ + "always_off", + "always_on", + "parent_base", + "trace_id_ratio" + ], + "title": "sampling strategy", + "type": "string" + }, + "options": { + "default": { + "fraction": 0, + "root": { + "name": "always_off" + } + }, + "properties": { + "fraction": { + "default": 0, + "title": "trace_id_ratio fraction", + "type": "number" + }, + "root": { + "default": { + "name": "always_off", + "options": { + "fraction": 0 + } + }, + "properties": { + "name": { + "default": "always_off", + "enum": [ + "always_off", + "always_on", + "trace_id_ratio" + ], + "title": "sampling strategy", + "type": "string" + }, + "options": { + "default": { + "fraction": 0 + }, + "properties": { + "fraction": { + "default": 0, + "title": "trace_id_ratio fraction parameter", + "type": "number" + } + }, + "type": "object" + } + }, + "title": "parent_base root sampler", + "type": "object" + } + }, + "type": "object" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "version": 0.1 + }, "openwhisk": { "priority": -1901, "schema": { @@ -5793,7 +6344,11 @@ "type": "boolean" }, "host": { - "pattern": "^http(s)?:\\/\\/[a-zA-Z0-9][-a-zA-Z0-9]{0,62}(\\.[a-zA-Z0-9][-a-zA-Z0-9]{0,62})+(:[0-9]{1,5})?$", + "pattern": "^http(s)?:\\/\\/([\\da-zA-Z.-]+|\\[[\\da-fA-F:]+\\])(:\\d+)?$", + "type": "string" + }, + "path": { + "pattern": "^/[^?&]+$", "type": "string" }, "sample_ratio": { @@ -5879,6 +6434,22 @@ }, "version": 0.1 }, + "public-api": { + "priority": 501, + "schema": { + "$comment": "this is a mark for our injected plugin schema", + "properties": { + "disable": { + "type": "boolean" + }, + "uri": { + "type": "string" + } + }, + "type": "object" + }, + "version": 0.1 + }, "real-ip": { "priority": 23000, "schema": { @@ -6355,7 +6926,7 @@ "version": 0.1 }, "skywalking": { - "priority": -1100, + "priority": 12010, "schema": { "$comment": "this is a mark for our injected plugin schema", "properties": { @@ -6647,7 +7218,6 @@ "type": "integer" }, "max_retry_times": { - "default": 1, "minimum": 1, "type": "integer" }, @@ -6669,7 +7239,6 @@ "type": "integer" }, "retry_interval": { - "default": 1, "minimum": 0, "type": "integer" }, @@ -7164,7 +7733,6 @@ }, "labels": { "description": "key/value pairs to specify attributes", - "maxProperties": 16, "patternProperties": { ".*": { "description": "value of label", @@ -7525,7 +8093,7 @@ "version": 0.1 }, "zipkin": { - "priority": 11011, + "priority": 12011, "schema": { "$comment": "this is a mark for our injected plugin schema", "properties": { diff --git a/api/go.mod b/api/go.mod index 257f9b7ecf..691ecfc074 100644 --- a/api/go.mod +++ b/api/go.mod @@ -15,7 +15,6 @@ require ( github.com/evanphx/json-patch/v5 v5.1.0 github.com/getkin/kin-openapi v0.33.0 github.com/gin-contrib/gzip v0.0.3 - github.com/gin-contrib/pprof v1.3.0 github.com/gin-contrib/static v0.0.0-20200916080430-d45d9a37d28e github.com/gin-gonic/gin v1.6.3 github.com/golang-jwt/jwt v3.2.2+incompatible diff --git a/api/go.sum b/api/go.sum index a3be3663bc..38aa782bd6 100644 --- a/api/go.sum +++ b/api/go.sum @@ -120,14 +120,11 @@ github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/gin-contrib/gzip v0.0.3 h1:etUaeesHhEORpZMp18zoOhepboiWnFtXrBZxszWUn4k= github.com/gin-contrib/gzip v0.0.3/go.mod h1:YxxswVZIqOvcHEQpsSn+QF5guQtO1dCfy0shBPy4jFc= -github.com/gin-contrib/pprof v1.3.0 h1:G9eK6HnbkSqDZBYbzG4wrjCsA4e+cvYAHUZw6W+W9K0= -github.com/gin-contrib/pprof v1.3.0/go.mod h1:waMjT1H9b179t3CxuG1cV3DHpga6ybizwfBaM5OXaB0= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-contrib/static v0.0.0-20200916080430-d45d9a37d28e h1:8bZpGwoPxkaivQPrAbWl+7zjjUcbFUnYp7yQcx2r2N0= github.com/gin-contrib/static v0.0.0-20200916080430-d45d9a37d28e/go.mod h1:VhW/Ch/3FhimwZb8Oj+qJmdMmoB8r7lmJ5auRjm50oQ= github.com/gin-gonic/gin v1.5.0/go.mod h1:Nd6IXA8m5kNZdNEHMBd93KT+mdY3+bewLgRvmCsR2Do= -github.com/gin-gonic/gin v1.6.2/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= github.com/gin-gonic/gin v1.6.3 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14= github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= @@ -274,6 +271,7 @@ github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= +github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= @@ -360,7 +358,9 @@ github.com/oklog/oklog v0.3.2/go.mod h1:FCV+B7mhrz4o+ueLpx+KqkyXRGMWOYEvfiXtdGtb github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= github.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs= github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk= github.com/opentracing-contrib/go-observer v0.0.0-20170622124052-a52f23424492/go.mod h1:Ngi6UdF0k5OKD5t5wlmGhe/EDKPoUM3BXZSSfIuJbis= @@ -881,6 +881,7 @@ gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogR gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/cheggaaa/pb.v1 v1.0.25/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o= gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= @@ -888,6 +889,7 @@ gopkg.in/go-playground/validator.v9 v9.29.1/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWd gopkg.in/ini.v1 v1.62.0 h1:duBzk771uxoUuOlyRLkHsygud9+5lrlGjdFBb4mSKDU= gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= diff --git a/api/internal/conf/conf.go b/api/internal/conf/conf.go index b0a7328e75..20074238fc 100644 --- a/api/internal/conf/conf.go +++ b/api/internal/conf/conf.go @@ -17,6 +17,7 @@ package conf import ( + "encoding/json" "fmt" "io/ioutil" "os" @@ -61,6 +62,7 @@ var ( ImportSizeLimit = 10 * 1024 * 1024 AllowList []string Plugins = map[string]bool{} + SecurityConf Security ) type MTLS struct { @@ -110,6 +112,7 @@ type Conf struct { Log Log AllowList []string `mapstructure:"allow_list"` MaxCpu int `mapstructure:"max_cpu"` + Security Security } type User struct { @@ -129,6 +132,15 @@ type Config struct { Plugins []string } +type Security struct { + AllowCredentials string `mapstructure:"access_control_allow_credentials"` + AllowOrigin string `mapstructure:"access_control_allow_origin"` + AllowMethods string `mapstructure:"access_control-allow_methods"` + AllowHeaders string `mapstructure:"access_control_allow_headers"` + XFrameOptions string `mapstructure:"x_frame_options"` + ContentSecurityPolicy string `mapstructure:"content_security_policy"` +} + // TODO: we should no longer use init() function after remove all handler's integration tests // ENV=test is for integration tests only, other ENV should call "InitConf" explicitly func init() { @@ -246,6 +258,9 @@ func setupConfig() { // set plugin initPlugins(config.Plugins) + + // security configuration + initSecurity(config.Conf.Security) } func setupEnv() { @@ -275,12 +290,54 @@ func initPlugins(plugins []string) { } func initSchema() { - filePath := WorkDir + "/conf/schema.json" - if schemaContent, err := ioutil.ReadFile(filePath); err != nil { - panic(fmt.Sprintf("fail to read configuration: %s", filePath)) - } else { - Schema = gjson.ParseBytes(schemaContent) + var ( + apisixSchemaPath = WorkDir + "/conf/schema.json" + customizeSchemaPath = WorkDir + "/conf/customize_schema.json" + apisixSchemaContent []byte + customizeSchemaContent []byte + err error + ) + + if apisixSchemaContent, err = ioutil.ReadFile(apisixSchemaPath); err != nil { + panic(fmt.Errorf("fail to read configuration: %s, error: %s", apisixSchemaPath, err.Error())) } + + if customizeSchemaContent, err = ioutil.ReadFile(customizeSchemaPath); err != nil { + panic(fmt.Errorf("fail to read configuration: %s, error: %s", customizeSchemaPath, err.Error())) + } + + content, err := mergeSchema(apisixSchemaContent, customizeSchemaContent) + if err != nil { + panic(err) + } + + Schema = gjson.ParseBytes(content) +} + +func mergeSchema(apisixSchema, customizeSchema []byte) ([]byte, error) { + var ( + apisixSchemaMap map[string]map[string]interface{} + customizeSchemaMap map[string]map[string]interface{} + ) + + if err := json.Unmarshal(apisixSchema, &apisixSchemaMap); err != nil { + return nil, err + } + if err := json.Unmarshal(customizeSchema, &customizeSchemaMap); err != nil { + return nil, err + } + + for key := range apisixSchemaMap["main"] { + if _, ok := customizeSchemaMap["main"][key]; ok { + return nil, fmt.Errorf("duplicates key: main.%s between schema.json and customize_schema.json", key) + } + } + + for k, v := range customizeSchemaMap["main"] { + apisixSchemaMap["main"][k] = v + } + + return json.Marshal(apisixSchemaMap) } // initialize etcd config @@ -316,3 +373,24 @@ func initParallelism(choiceCores int) { } runtime.GOMAXPROCS(choiceCores) } + +// initialize security settings +func initSecurity(conf Security) { + var se Security + // if conf == se, then conf is empty, we should use default value + if conf != se { + SecurityConf = conf + if conf.ContentSecurityPolicy == "" { + SecurityConf.ContentSecurityPolicy = "default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'" + } + if conf.XFrameOptions == "" { + SecurityConf.XFrameOptions = "deny" + } + return + } + + SecurityConf = Security{ + XFrameOptions: "deny", + ContentSecurityPolicy: "default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'", + } +} diff --git a/api/internal/conf/conf_test.go b/api/internal/conf/conf_test.go new file mode 100644 index 0000000000..1a1a1c3ef3 --- /dev/null +++ b/api/internal/conf/conf_test.go @@ -0,0 +1,63 @@ +package conf + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_mergeSchema(t *testing.T) { + type args struct { + apisixSchema []byte + customizeSchema []byte + } + tests := []struct { + name string + args args + wantRes []byte + wantErr bool + wantErrMessage string + }{ + { + name: "should failed when have duplicates key", + args: args{ + apisixSchema: []byte(`{"main":{"a":1,"b":2},"plugins":{"a":1}}`), + customizeSchema: []byte(`{"main":{"b":1}}`), + }, + wantErr: true, + wantErrMessage: "duplicates key: main.b between schema.json and customize_schema.json", + }, + { + name: "should success", + args: args{ + apisixSchema: []byte(`{"main":{"a":1,"b":2},"plugins":{"a":1}}`), + customizeSchema: []byte(`{"main":{"c":3}}`), + }, + wantErr: false, + wantRes: []byte(`{"main":{"a":1,"b":2,"c":3},"plugins":{"a":1}}`), + }, + } + for _, tt := range tests { + + t.Run(tt.name, func(t *testing.T) { + var ( + wantMap map[string]interface{} + gotMap map[string]interface{} + ) + + got, err := mergeSchema(tt.args.apisixSchema, tt.args.customizeSchema) + if tt.wantErr { + assert.Equal(t, tt.wantErrMessage, err.Error()) + return + } + + assert.NoError(t, err) + err = json.Unmarshal(got, &gotMap) + assert.NoError(t, err) + err = json.Unmarshal(tt.wantRes, &wantMap) + assert.NoError(t, err) + assert.Equal(t, wantMap, gotMap) + }) + } +} diff --git a/api/internal/core/entity/entity.go b/api/internal/core/entity/entity.go index f7d4857057..f8fcc1c604 100644 --- a/api/internal/core/entity/entity.go +++ b/api/internal/core/entity/entity.go @@ -133,7 +133,7 @@ type Active struct { Host string `json:"host,omitempty"` Port int `json:"port,omitempty"` HTTPPath string `json:"http_path,omitempty"` - HTTPSVerifyCertificate string `json:"https_verify_certificate,omitempty"` + HTTPSVerifyCertificate bool `json:"https_verify_certificate,omitempty"` Healthy Healthy `json:"healthy,omitempty"` UnHealthy UnHealthy `json:"unhealthy,omitempty"` ReqHeaders []string `json:"req_headers,omitempty"` @@ -304,3 +304,12 @@ type StreamRoute struct { UpstreamID interface{} `json:"upstream_id,omitempty"` Plugins map[string]interface{} `json:"plugins,omitempty"` } + +// swagger:model SystemConfig +type SystemConfig struct { + ConfigName string `json:"config_name"` + Desc string `json:"desc,omitempty"` + Payload map[string]interface{} `json:"payload,omitempty"` + CreateTime int64 `json:"create_time,omitempty"` + UpdateTime int64 `json:"update_time,omitempty"` +} diff --git a/api/internal/core/entity/format.go b/api/internal/core/entity/format.go index 30afaa0085..c13bb6cb91 100644 --- a/api/internal/core/entity/format.go +++ b/api/internal/core/entity/format.go @@ -17,17 +17,23 @@ package entity import ( - "net" + "errors" "strconv" + "strings" "github.com/apisix/manager-api/internal/log" ) func mapKV2Node(key string, val float64) (*Node, error) { - host, port, err := net.SplitHostPort(key) - if err != nil { - log.Errorf("split host port fail: %s", err) - return nil, err + hp := strings.Split(key, ":") + host := hp[0] + // according to APISIX upstream nodes policy, port is optional + port := "0" + + if len(hp) > 2 { + return nil, errors.New("invalid upstream node") + } else if len(hp) == 2 { + port = hp[1] } portInt, err := strconv.Atoi(port) diff --git a/api/internal/core/entity/format_test.go b/api/internal/core/entity/format_test.go index 810c43dfac..109aa384e8 100644 --- a/api/internal/core/entity/format_test.go +++ b/api/internal/core/entity/format_test.go @@ -180,6 +180,17 @@ func TestNodesFormat_no_nodes(t *testing.T) { assert.Contains(t, jsonStr, `null`) } +func TestNodesFormat_nodes_without_port(t *testing.T) { + nodes := map[string]float64{"127.0.0.1": 0} + // nodes format + formattedNodes := NodesFormat(nodes) + + // json encode for client + res, err := json.Marshal(formattedNodes) + assert.Nil(t, err) + assert.Equal(t, res, []byte(`[{"host":"127.0.0.1","weight":0}]`)) +} + func Test_Idle_Timeout_nil_and_zero(t *testing.T) { ukp0 := UpstreamKeepalivePool{} // Unmarshal from zero value @@ -229,3 +240,56 @@ func TestUpstream_nil_and_zero_retries(t *testing.T) { assert.Nil(t, err) assert.Equal(t, string(marshaledNull), `{}`) } + +func TestMapKV2Node(t *testing.T) { + testCases := []struct { + name string + key string + value float64 + wantErr bool + errMessage string + wantRes *Node + }{ + { + name: "invalid upstream node", + key: "127.0.0.1:0:0", + wantErr: true, + errMessage: "invalid upstream node", + }, + { + name: "when address contains port convert should succeed", + key: "127.0.0.1:8080", + value: 100, + wantErr: false, + wantRes: &Node{ + Host: "127.0.0.1", + Port: 8080, + Weight: 100, + }, + }, + { + name: "when address without port convert should succeed", + key: "127.0.0.1", + wantErr: false, + wantRes: &Node{ + Host: "127.0.0.1", + Port: 0, + Weight: 0, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got, err := mapKV2Node(tc.key, tc.value) + if tc.wantErr { + assert.NotNil(t, err) + assert.Contains(t, err.Error(), tc.errMessage) + return + } + + assert.Nil(t, err) + assert.Equal(t, tc.wantRes, got) + }) + } +} diff --git a/api/internal/core/store/storehub.go b/api/internal/core/store/storehub.go index 1184a3f880..9efc685511 100644 --- a/api/internal/core/store/storehub.go +++ b/api/internal/core/store/storehub.go @@ -40,6 +40,7 @@ const ( HubKeyPluginConfig HubKey = "plugin_config" HubKeyProto HubKey = "proto" HubKeyStreamRoute HubKey = "stream_route" + HubKeySystemConfig HubKey = "system_config" ) var ( @@ -48,14 +49,16 @@ var ( func InitStore(key HubKey, opt GenericStoreOption) error { hubsNeedCheck := map[HubKey]bool{ - HubKeyConsumer: true, - HubKeyRoute: true, - HubKeySsl: true, - HubKeyService: true, - HubKeyUpstream: true, - HubKeyGlobalRule: true, - HubKeyStreamRoute: true, + HubKeyConsumer: true, + HubKeyRoute: true, + HubKeySsl: true, + HubKeyService: true, + HubKeyUpstream: true, + HubKeyGlobalRule: true, + HubKeyStreamRoute: true, + HubKeySystemConfig: true, } + if _, ok := hubsNeedCheck[key]; ok { validator, err := NewAPISIXJsonSchemaValidator("main." + string(key)) if err != nil { @@ -229,5 +232,17 @@ func InitStores() error { return err } + err = InitStore(HubKeySystemConfig, GenericStoreOption{ + BasePath: conf.ETCDConfig.Prefix + "/system_config", + ObjType: reflect.TypeOf(entity.SystemConfig{}), + KeyFunc: func(obj interface{}) string { + r := obj.(*entity.SystemConfig) + return r.ConfigName + }, + }) + if err != nil { + return err + } + return nil } diff --git a/api/internal/core/store/test_case.json b/api/internal/core/store/test_case.json index 950edf34d9..616dd5514f 100644 --- a/api/internal/core/store/test_case.json +++ b/api/internal/core/store/test_case.json @@ -1,19 +1,19 @@ { - "$schema": "http://json-schema.org/draft-04/schema#", - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 10 + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 10 + }, + "email": { + "type": "string", + "maxLength": 10 + }, + "age": { + "type": "integer", + "minimum": 0 + } }, - "email": { - "type": "string", - "maxLength": 10 - }, - "age": { - "type": "integer", - "minimum": 0 - } - }, - "additionalProperties": false + "additionalProperties": false } diff --git a/api/internal/core/store/validate.go b/api/internal/core/store/validate.go index abf5933185..94d7fe71c3 100644 --- a/api/internal/core/store/validate.go +++ b/api/internal/core/store/validate.go @@ -166,8 +166,11 @@ func checkUpstream(upstream *entity.UpstreamDef) error { } if upstream.PassHost == "node" && upstream.Nodes != nil { - if nodes := entity.NodesFormat(upstream.Nodes); len(nodes.([]*entity.Node)) != 1 { - return fmt.Errorf("only support single node for `node` mode currently") + nodes, ok := entity.NodesFormat(upstream.Nodes).([]*entity.Node) + if !ok { + return fmt.Errorf("upstrams nodes not support value %v when `pass_host` is `node`", nodes) + } else if len(nodes) != 1 { + return fmt.Errorf("only support single node for `node` mode currentlywhen `pass_host` is `node`") } } diff --git a/api/internal/core/store/validate_test.go b/api/internal/core/store/validate_test.go index 805049975a..6ea2466ee7 100644 --- a/api/internal/core/store/validate_test.go +++ b/api/internal/core/store/validate_test.go @@ -82,8 +82,9 @@ func TestAPISIXJsonSchemaValidator_Validate(t *testing.T) { "count": 2, "time_window": 60, "rejected_code": 503, - "key": "remote_addr" - } + "key": "remote_addr", + "policy": "local" + } }, "desc": "test description" }` @@ -105,7 +106,8 @@ func TestAPISIXJsonSchemaValidator_Validate(t *testing.T) { "limit-count": { "time_window": 60, "rejected_code": 503, - "key": "remote_addr" + "key": "remote_addr", + "policy": "local" } }, "desc": "test description" @@ -428,6 +430,82 @@ func TestAPISIXJsonSchemaValidator_Route_checkRemoteAddr(t *testing.T) { } } +func TestAPISIXSchemaValidator_SystemConfig(t *testing.T) { + tests := []struct { + name string + givePath string + giveObj interface{} + wantNewErr bool + wantValidateErr bool + wantErrMessage string + }{ + { + name: "new json schema validator failed", + givePath: "main.xxx", + wantNewErr: true, + wantErrMessage: "schema validate failed: schema not found, path: main.xxx", + }, + { + name: "invalid configName (configName is empty)", + givePath: "main.system_config", + giveObj: &entity.SystemConfig{ + Payload: map[string]interface{}{"a": 1}, + }, + wantValidateErr: true, + wantErrMessage: "schema validate failed: config_name: String length must be greater than or equal to 1\nconfig_name: Does not match pattern '^[a-zA-Z0-9_]+$'", + }, + { + name: "invalid configName (configName do not match regex)", + givePath: "main.system_config", + giveObj: &entity.SystemConfig{ + ConfigName: "1@2", + Payload: map[string]interface{}{"a": 1}, + }, + wantValidateErr: true, + wantErrMessage: "schema validate failed: config_name: Does not match pattern '^[a-zA-Z0-9_]+$'", + }, + { + name: "invalid payload", + givePath: "main.system_config", + giveObj: &entity.SystemConfig{ + ConfigName: "cc", + }, + wantValidateErr: true, + wantErrMessage: "schema validate failed: (root): payload is required", + }, + { + name: "validate should succeed", + givePath: "main.system_config", + giveObj: &entity.SystemConfig{ + ConfigName: "aaa", + Payload: map[string]interface{}{"a": 1}, + }, + }, + } + + for _, tc := range tests { + validator, err := NewAPISIXSchemaValidator(tc.givePath) + if tc.wantNewErr { + assert.Error(t, err) + assert.Equal(t, tc.wantErrMessage, err.Error()) + continue + } + + assert.NoError(t, err) + assert.NotNil(t, validator) + + req, err := json.Marshal(tc.giveObj) + assert.NoError(t, err) + err = validator.Validate(req) + if tc.wantValidateErr { + assert.Error(t, err) + assert.Equal(t, tc.wantErrMessage, err.Error()) + continue + } + assert.NoError(t, err) + } +} + func TestAPISIXSchemaValidator_Validate(t *testing.T) { validator, err := NewAPISIXSchemaValidator("main.consumer") assert.Nil(t, err) diff --git a/api/internal/filter/cors.go b/api/internal/filter/cors.go index b33c62b94f..28ca331625 100644 --- a/api/internal/filter/cors.go +++ b/api/internal/filter/cors.go @@ -16,14 +16,37 @@ */ package filter -import "github.com/gin-gonic/gin" +import ( + "github.com/gin-gonic/gin" + + "github.com/apisix/manager-api/internal/conf" +) func CORS() gin.HandlerFunc { return func(c *gin.Context) { - c.Writer.Header().Set("Access-Control-Allow-Origin", "*") - c.Writer.Header().Set("Access-Control-Allow-Credentials", "true") - c.Writer.Header().Set("Access-Control-Allow-Headers", "Authorization") - c.Writer.Header().Set("Access-Control-Allow-Methods", "*") + if conf.SecurityConf.AllowOrigin != "" { + c.Writer.Header().Set("Access-Control-Allow-Origin", conf.SecurityConf.AllowOrigin) + } + + if conf.SecurityConf.AllowHeaders != "" { + c.Writer.Header().Set("Access-Control-Allow-Headers", conf.SecurityConf.AllowHeaders) + } + + if conf.SecurityConf.AllowMethods != "" { + c.Writer.Header().Set("Access-Control-Allow-Methods", conf.SecurityConf.AllowMethods) + } + + if conf.SecurityConf.AllowCredentials != "" { + c.Writer.Header().Set("Access-Control-Allow-Credentials", conf.SecurityConf.AllowCredentials) + } + + if conf.SecurityConf.XFrameOptions != "" { + c.Writer.Header().Set("X-Frame-Options", conf.SecurityConf.XFrameOptions) + } + + if conf.SecurityConf.ContentSecurityPolicy != "" { + c.Writer.Header().Set("Content-Security-Policy", conf.SecurityConf.ContentSecurityPolicy) + } if c.Request.Method == "OPTIONS" { c.AbortWithStatus(204) return diff --git a/api/internal/handler/data_loader/route_export.go b/api/internal/handler/data_loader/route_export.go index dd0c3c5086..a61d5d1ed5 100644 --- a/api/internal/handler/data_loader/route_export.go +++ b/api/internal/handler/data_loader/route_export.go @@ -224,6 +224,10 @@ func (h *Handler) RouteToOpenAPI3(c droplet.Context, routes []*entity.Route) (*o extensions["x-apisix-vars"] = route.Vars } + if route.ID != nil { + extensions["x-apisix-id"] = route.ID + } + // Parse Route URIs paths, paramsRefs = ParseRouteUris(route, paths, paramsRefs, pathItem, _pathNumber()) diff --git a/api/internal/handler/data_loader/route_import.go b/api/internal/handler/data_loader/route_import.go index e34a8527c9..4f17dd0849 100644 --- a/api/internal/handler/data_loader/route_import.go +++ b/api/internal/handler/data_loader/route_import.go @@ -137,6 +137,22 @@ func (h *ImportHandler) Import(c droplet.Context) (interface{}, error) { } } + // merge route + idRoute := make(map[string]*entity.Route) + for _, route := range routes { + if existRoute, ok := idRoute[route.ID.(string)]; ok { + uris := append(existRoute.Uris, route.Uris...) + existRoute.Uris = uris + } else { + idRoute[route.ID.(string)] = route + } + } + + routes = make([]*entity.Route, 0, len(idRoute)) + for _, route := range idRoute { + routes = append(routes, route) + } + // create route for _, route := range routes { if Force && route.ID != nil { @@ -168,7 +184,25 @@ func checkRouteExist(ctx context.Context, routeStore *store.GenericStore, route return false } - if !(item.Host == route.Host && item.URI == route.URI && utils.StringSliceEqual(item.Uris, route.Uris) && + itemUris := item.Uris + if item.URI != "" { + if itemUris == nil { + itemUris = []string{item.URI} + } else { + itemUris = append(itemUris, item.URI) + } + } + + routeUris := route.Uris + if route.URI != "" { + if routeUris == nil { + routeUris = []string{route.URI} + } else { + routeUris = append(routeUris, route.URI) + } + } + + if !(item.Host == route.Host && utils.StringSliceContains(itemUris, routeUris) && utils.StringSliceEqual(item.RemoteAddrs, route.RemoteAddrs) && item.RemoteAddr == route.RemoteAddr && utils.StringSliceEqual(item.Hosts, route.Hosts) && item.Priority == route.Priority && utils.ValueEqual(item.Vars, route.Vars) && item.FilterFunc == route.FilterFunc) { diff --git a/api/internal/handler/system_config/system_config.go b/api/internal/handler/system_config/system_config.go new file mode 100644 index 0000000000..154c029da9 --- /dev/null +++ b/api/internal/handler/system_config/system_config.go @@ -0,0 +1,109 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package system_config + +import ( + "reflect" + "time" + + "github.com/gin-gonic/gin" + "github.com/shiningrush/droplet" + "github.com/shiningrush/droplet/wrapper" + wgin "github.com/shiningrush/droplet/wrapper/gin" + + "github.com/apisix/manager-api/internal/core/entity" + "github.com/apisix/manager-api/internal/core/store" + "github.com/apisix/manager-api/internal/handler" +) + +type Handler struct { + systemConfig store.Interface +} + +func NewHandler() (handler.RouteRegister, error) { + return &Handler{ + systemConfig: store.GetStore(store.HubKeySystemConfig), + }, nil +} + +func (h *Handler) ApplyRoute(r *gin.Engine) { + r.GET("/apisix/admin/system_config/:config_name", wgin.Wraps(h.Get, + wrapper.InputType(reflect.TypeOf(GetInput{})))) + r.POST("/apisix/admin/system_config", wgin.Wraps(h.Post, + wrapper.InputType(reflect.TypeOf(entity.SystemConfig{})))) + r.PUT("/apisix/admin/system_config", wgin.Wraps(h.Put, + wrapper.InputType(reflect.TypeOf(entity.SystemConfig{})))) + r.DELETE("/apisix/admin/system_config/:config_name", wgin.Wraps(h.Delete, + wrapper.InputType(reflect.TypeOf(DeleteInput{})))) +} + +type GetInput struct { + ConfigName string `auto_read:"config_name,path" validate:"required"` +} + +func (h *Handler) Get(c droplet.Context) (interface{}, error) { + input := c.Input().(*GetInput) + r, err := h.systemConfig.Get(c.Context(), input.ConfigName) + + if err != nil { + return handler.SpecCodeResponse(err), err + } + + return r, nil +} + +func (h *Handler) Post(c droplet.Context) (interface{}, error) { + input := c.Input().(*entity.SystemConfig) + input.CreateTime = time.Now().Unix() + input.UpdateTime = time.Now().Unix() + + // create + res, err := h.systemConfig.Create(c.Context(), input) + if err != nil { + return handler.SpecCodeResponse(err), err + } + + return res, nil +} + +func (h *Handler) Put(c droplet.Context) (interface{}, error) { + input := c.Input().(*entity.SystemConfig) + input.UpdateTime = time.Now().Unix() + + // update + res, err := h.systemConfig.Update(c.Context(), input, false) + if err != nil { + return handler.SpecCodeResponse(err), err + } + + return res, nil +} + +type DeleteInput struct { + ConfigName string `auto_read:"config_name,path" validate:"required"` +} + +func (h *Handler) Delete(c droplet.Context) (interface{}, error) { + input := c.Input().(*DeleteInput) + err := h.systemConfig.BatchDelete(c.Context(), []string{input.ConfigName}) + + if err != nil { + return handler.SpecCodeResponse(err), err + } + + return nil, nil +} diff --git a/api/internal/handler/system_config/system_config_test.go b/api/internal/handler/system_config/system_config_test.go new file mode 100644 index 0000000000..ca5d6acc5b --- /dev/null +++ b/api/internal/handler/system_config/system_config_test.go @@ -0,0 +1,258 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package system_config + +import ( + "errors" + "testing" + + "github.com/shiningrush/droplet" + "github.com/shiningrush/droplet/data" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + + "github.com/apisix/manager-api/internal/core/entity" + "github.com/apisix/manager-api/internal/core/store" +) + +func TestSystem_Get(t *testing.T) { + t.Parallel() + type testCase struct { + caseDesc string + giveInput *GetInput + wantErr error + wantRet interface{} + mockStore store.Interface + mockFunc func(tc *testCase) + } + + cases := []*testCase{ + { + caseDesc: "system config not found", + giveInput: &GetInput{ConfigName: "grafana"}, + wantErr: data.ErrNotFound, + mockFunc: func(tc *testCase) { + mockStore := &store.MockInterface{} + mockStore.On("Get", mock.Anything, mock.Anything).Return(nil, tc.wantErr) + tc.mockStore = mockStore + }, + }, + { + caseDesc: "get system config success", + giveInput: &GetInput{ConfigName: "grafana"}, + wantErr: nil, + wantRet: entity.SystemConfig{ + ConfigName: "grafana", + Payload: map[string]interface{}{ + "url": "http://127.0.0.1:3000", + }, + }, + mockFunc: func(tc *testCase) { + mockStore := &store.MockInterface{} + mockStore.On("Get", mock.Anything, mock.Anything).Return(tc.wantRet, nil) + tc.mockStore = mockStore + }, + }, + } + + for _, tc := range cases { + t.Run(tc.caseDesc, func(t *testing.T) { + tc.mockFunc(tc) + h := Handler{tc.mockStore} + ctx := droplet.NewContext() + ctx.SetInput(tc.giveInput) + ret, err := h.Get(ctx) + assert.Equal(t, err, tc.wantErr) + if err == nil { + assert.Equal(t, ret, tc.wantRet) + } + }) + } +} + +func TestSystem_Post(t *testing.T) { + t.Parallel() + type testCase struct { + caseDesc string + giveInput *entity.SystemConfig + wantErr error + wantRet interface{} + mockStore store.Interface + mockFunc func(tc *testCase) + } + + systemConfig := entity.SystemConfig{ + ConfigName: "grafana", + Payload: map[string]interface{}{ + "url": "http://127.0.0.1:3000", + }, + } + + cases := []*testCase{ + { + caseDesc: "create system config error", + giveInput: &systemConfig, + wantErr: errors.New("mock error"), + mockFunc: func(tc *testCase) { + mockStore := &store.MockInterface{} + mockStore.On("Create", mock.Anything, mock.Anything).Return(nil, tc.wantErr) + tc.mockStore = mockStore + }, + }, + { + caseDesc: "create system config success", + giveInput: &systemConfig, + wantErr: nil, + wantRet: entity.SystemConfig{ + ConfigName: "grafana", + Payload: map[string]interface{}{ + "url": "http://127.0.0.1:3000", + }, + }, + mockFunc: func(tc *testCase) { + mockStore := &store.MockInterface{} + mockStore.On("Create", mock.Anything, mock.Anything).Return(tc.wantRet, nil) + tc.mockStore = mockStore + }, + }, + } + + for _, tc := range cases { + t.Run(tc.caseDesc, func(t *testing.T) { + tc.mockFunc(tc) + h := Handler{tc.mockStore} + ctx := droplet.NewContext() + ctx.SetInput(tc.giveInput) + ret, err := h.Post(ctx) + assert.Equal(t, err, tc.wantErr) + if err == nil { + assert.Equal(t, ret, tc.wantRet) + } + }) + } +} + +func TestSystem_Put(t *testing.T) { + t.Parallel() + type testCase struct { + caseDesc string + giveInput *entity.SystemConfig + wantErr error + wantRet interface{} + mockStore store.Interface + mockFunc func(tc *testCase) + } + + systemConfig := entity.SystemConfig{ + ConfigName: "grafana", + Payload: map[string]interface{}{ + "url": "http://127.0.0.1:3000", + }, + } + + cases := []*testCase{ + { + caseDesc: "update system config error", + giveInput: &systemConfig, + wantErr: errors.New("mock error"), + mockFunc: func(tc *testCase) { + mockStore := &store.MockInterface{} + mockStore.On("Update", mock.Anything, mock.Anything, mock.Anything).Return(nil, tc.wantErr) + tc.mockStore = mockStore + }, + }, + { + caseDesc: "update system config success", + giveInput: &systemConfig, + wantErr: nil, + wantRet: entity.SystemConfig{ + ConfigName: "grafana", + Payload: map[string]interface{}{ + "url": "http://127.0.0.1:3000", + }, + }, + mockFunc: func(tc *testCase) { + mockStore := &store.MockInterface{} + mockStore.On("Update", mock.Anything, mock.Anything, mock.Anything).Return(tc.wantRet, nil) + tc.mockStore = mockStore + }, + }, + } + + for _, tc := range cases { + t.Run(tc.caseDesc, func(t *testing.T) { + tc.mockFunc(tc) + h := Handler{tc.mockStore} + ctx := droplet.NewContext() + ctx.SetInput(tc.giveInput) + ret, err := h.Put(ctx) + assert.Equal(t, err, tc.wantErr) + if err == nil { + assert.Equal(t, ret, tc.wantRet) + } + }) + } +} + +func TestSystem_Delete(t *testing.T) { + t.Parallel() + type testCase struct { + caseDesc string + giveInput *DeleteInput + wantErr error + wantRet interface{} + mockStore store.Interface + mockFunc func(tc *testCase) + } + + cases := []*testCase{ + { + caseDesc: "delete system config error", + giveInput: &DeleteInput{ConfigName: "grafana"}, + wantErr: errors.New("mock error"), + mockFunc: func(tc *testCase) { + mockStore := &store.MockInterface{} + mockStore.On("BatchDelete", mock.Anything, mock.Anything).Return(tc.wantErr) + tc.mockStore = mockStore + }, + }, + { + caseDesc: "delete system config success", + giveInput: &DeleteInput{ConfigName: "grafana"}, + wantErr: nil, + mockFunc: func(tc *testCase) { + mockStore := &store.MockInterface{} + mockStore.On("BatchDelete", mock.Anything, mock.Anything).Return(tc.wantRet) + tc.mockStore = mockStore + }, + }, + } + + for _, tc := range cases { + t.Run(tc.caseDesc, func(t *testing.T) { + tc.mockFunc(tc) + h := Handler{tc.mockStore} + ctx := droplet.NewContext() + ctx.SetInput(tc.giveInput) + ret, err := h.Delete(ctx) + assert.Equal(t, err, tc.wantErr) + if err == nil { + assert.Equal(t, ret, tc.wantRet) + } + }) + } +} diff --git a/api/internal/handler/upstream/upstream_test.go b/api/internal/handler/upstream/upstream_test.go index 3e3b632a9a..8b4941e203 100644 --- a/api/internal/handler/upstream/upstream_test.go +++ b/api/internal/handler/upstream/upstream_test.go @@ -533,6 +533,75 @@ func TestUpstream_Create(t *testing.T) { }, wantErr: nil, }, + { + caseDesc: "when nodes address without port and pass host is node, create should succeed", + getCalled: true, + giveInput: &entity.Upstream{ + BaseInfo: entity.BaseInfo{ + ID: "u1", + }, + UpstreamDef: entity.UpstreamDef{ + Name: "upstream1", + Timeout: &entity.Timeout{ + Connect: 15, + Send: 15, + Read: 15, + }, + Key: "server_addr", + Nodes: map[string]float64{"127.0.0.1": 100}, + PassHost: "node", + }, + }, + giveRet: &entity.Upstream{ + BaseInfo: entity.BaseInfo{ + ID: "u1", + }, + UpstreamDef: entity.UpstreamDef{ + Name: "upstream1", + Timeout: &entity.Timeout{ + Connect: 15, + Send: 15, + Read: 15, + }, + Key: "server_addr", + Nodes: map[string]float64{"127.0.0.1": 100}, + PassHost: "node", + }, + }, + wantInput: &entity.Upstream{ + BaseInfo: entity.BaseInfo{ + ID: "u1", + }, + UpstreamDef: entity.UpstreamDef{ + Name: "upstream1", + Timeout: &entity.Timeout{ + Connect: 15, + Send: 15, + Read: 15, + }, + Key: "server_addr", + Nodes: map[string]float64{"127.0.0.1": 100}, + PassHost: "node", + }, + }, + wantRet: &entity.Upstream{ + BaseInfo: entity.BaseInfo{ + ID: "u1", + }, + UpstreamDef: entity.UpstreamDef{ + Name: "upstream1", + Timeout: &entity.Timeout{ + Connect: 15, + Send: 15, + Read: 15, + }, + Key: "server_addr", + Nodes: map[string]float64{"127.0.0.1": 100}, + PassHost: "node", + }, + }, + wantErr: nil, + }, { caseDesc: "create failed, create return error", getCalled: true, diff --git a/api/internal/log/zap.go b/api/internal/log/zap.go index 1f3f44a94e..446dde776d 100644 --- a/api/internal/log/zap.go +++ b/api/internal/log/zap.go @@ -43,16 +43,18 @@ func InitLogger() { func GetLogger(logType Type) *zap.SugaredLogger { _ = zap.RegisterSink("winfile", newWinFileSink) + skip := 2 writeSyncer := fileWriter(logType) encoder := getEncoder(logType) logLevel := getLogLevel() if logType == AccessLog { logLevel = zapcore.InfoLevel + skip = 0 } core := zapcore.NewCore(encoder, writeSyncer, logLevel) - zapLogger := zap.New(core, zap.AddCaller(), zap.AddCallerSkip(2)) + zapLogger := zap.New(core, zap.AddCaller(), zap.AddCallerSkip(skip)) return zapLogger.Sugar() } diff --git a/api/internal/route.go b/api/internal/route.go index 28fc2cd437..38db621a68 100644 --- a/api/internal/route.go +++ b/api/internal/route.go @@ -22,6 +22,7 @@ import ( "path/filepath" // "github.com/gin-contrib/pprof" + "github.com/gin-contrib/gzip" "github.com/gin-contrib/static" "github.com/gin-gonic/gin" @@ -43,10 +44,10 @@ import ( "github.com/apisix/manager-api/internal/handler/service" "github.com/apisix/manager-api/internal/handler/ssl" "github.com/apisix/manager-api/internal/handler/stream_route" + "github.com/apisix/manager-api/internal/handler/system_config" "github.com/apisix/manager-api/internal/handler/tool" "github.com/apisix/manager-api/internal/handler/upstream" "github.com/apisix/manager-api/internal/log" - "github.com/gin-contrib/gzip" ) func SetUpRouter() *gin.Engine { @@ -84,7 +85,11 @@ func SetUpRouter() *gin.Engine { migrate.NewHandler, proto.NewHandler, stream_route.NewHandler, +<<<<<<< HEAD statistic.NewHandler, +======= + system_config.NewHandler, +>>>>>>> upstream } for i := range factories { diff --git a/api/internal/utils/utils.go b/api/internal/utils/utils.go index 4d8150b9a4..c7962bed1c 100644 --- a/api/internal/utils/utils.go +++ b/api/internal/utils/utils.go @@ -172,6 +172,22 @@ func ValidateLuaCode(code string) error { return err } +func StringSliceContains(a, b []string) bool { + if (a == nil) != (b == nil) { + return false + } + + for i := range a { + for j := range b { + if a[i] == b[j] { + return true + } + } + } + + return false +} + // func StringSliceEqual(a, b []string) bool { if (a == nil) != (b == nil) { diff --git a/api/test/docker/Dockerfile b/api/test/docker/Dockerfile index 4ef6eaae55..072960b27c 100644 --- a/api/test/docker/Dockerfile +++ b/api/test/docker/Dockerfile @@ -26,6 +26,7 @@ RUN mkdir -p /go/manager-api/conf \ && mv /go/src/github.com/apisix/manager-api/entry.sh /go/manager-api/ \ && mv /go/src/github.com/apisix/manager-api/conf/conf.yaml /go/manager-api/conf/conf.yaml \ && mv /go/src/github.com/apisix/manager-api/conf/schema.json /go/manager-api/conf/schema.json \ + && mv /go/src/github.com/apisix/manager-api/conf/customize_schema.json /go/manager-api/conf/customize_schema.json \ && rm -rf /go/src/github.com/apisix/manager-api \ && rm -rf /etc/localtime \ && ln -s /usr/share/zoneinfo/Hongkong /etc/localtime \ diff --git a/api/test/e2e/route_import_test.go b/api/test/e2e/route_import_test.go index a57533b81a..3043abb484 100644 --- a/api/test/e2e/route_import_test.go +++ b/api/test/e2e/route_import_test.go @@ -577,3 +577,137 @@ func TestRoute_export_import(t *testing.T) { testCaseCheck(tc, t) } } + +func TestRoute_export_import_merge(t *testing.T) { + // create routes + tests := []HttpTestCase{ + { + Desc: "Create a route", + Object: ManagerApiExpect(t), + Method: http.MethodPut, + Path: "/apisix/admin/routes/r1", + Body: `{ + "id": "r1", + "uris": ["/test1", "/test2"], + "name": "route_all", + "desc": "所有", + "methods": ["GET","POST","PUT","DELETE"], + "hosts": ["test.com"], + "status": 1, + "upstream": { + "nodes": { + "` + UpstreamIp + `:1980": 1 + }, + "type": "roundrobin" + } + }`, + Headers: map[string]string{"Authorization": token}, + ExpectStatus: http.StatusOK, + Sleep: sleepTime, + }, + } + for _, tc := range tests { + testCaseCheck(tc, t) + } + + // export routes + time.Sleep(sleepTime) + tmpPath := "/tmp/export.json" + headers := map[string]string{ + "Authorization": token, + } + body, status, err := httpGet(ManagerAPIHost+"/apisix/admin/export/routes", headers) + assert.Nil(t, err) + assert.Equal(t, http.StatusOK, status) + + content := gjson.Get(string(body), "data") + err = ioutil.WriteFile(tmpPath, []byte(content.Raw), 0644) + assert.Nil(t, err) + + // import routes (should failed -- duplicate) + files := []UploadFile{ + {Name: "file", Filepath: tmpPath}, + } + respBody, status, err := PostFile(ManagerAPIHost+"/apisix/admin/import/routes", nil, files, headers) + assert.Nil(t, err) + assert.Equal(t, 400, status) + assert.True(t, strings.Contains(string(respBody), "duplicate")) + time.Sleep(sleepTime) + + // delete routes + tests = []HttpTestCase{ + { + Desc: "delete the route1 just created", + Object: ManagerApiExpect(t), + Method: http.MethodDelete, + Path: "/apisix/admin/routes/r1", + Headers: map[string]string{"Authorization": token}, + ExpectStatus: http.StatusOK, + }, + } + for _, tc := range tests { + testCaseCheck(tc, t) + } + + // import again + time.Sleep(sleepTime) + respBody, status, err = PostFile(ManagerAPIHost+"/apisix/admin/import/routes", nil, files, headers) + assert.Nil(t, err) + assert.Equal(t, 200, status) + assert.True(t, strings.Contains(string(respBody), `"data":{"paths":2,"routes":1}`)) + time.Sleep(sleepTime) + + // sleep for data sync + time.Sleep(sleepTime) + + request, _ := http.NewRequest("GET", ManagerAPIHost+"/apisix/admin/routes", nil) + request.Header.Add("Authorization", token) + resp, err := http.DefaultClient.Do(request) + assert.Nil(t, err) + defer resp.Body.Close() + respBody, _ = ioutil.ReadAll(resp.Body) + list := gjson.Get(string(respBody), "data.rows").Value().([]interface{}) + + assert.Equal(t, 1, len(list)) + + // verify route data + tests = []HttpTestCase{} + for _, item := range list { + route := item.(map[string]interface{}) + tcDataVerify := HttpTestCase{ + Desc: "verify data of route2", + Object: ManagerApiExpect(t), + Method: http.MethodGet, + Path: "/apisix/admin/routes/" + route["id"].(string), + Headers: map[string]string{"Authorization": token}, + ExpectStatus: http.StatusOK, + ExpectBody: []string{`"methods":["GET","POST","PUT","DELETE"]`, + `"/test1"`, + `"/test2"`, + `"desc":"所有`, + `"hosts":["test.com"]`, + `"upstream":{"nodes":[{"host":"` + UpstreamIp + `","port":1980,"weight":1}],"type":"roundrobin"}`, + }, + Sleep: sleepTime, + } + tests = append(tests, tcDataVerify) + } + + // delete test data + for _, item := range list { + route := item.(map[string]interface{}) + tc := HttpTestCase{ + Desc: "delete route", + Object: ManagerApiExpect(t), + Method: http.MethodDelete, + Path: "/apisix/admin/routes/" + route["id"].(string), + Headers: map[string]string{"Authorization": token}, + ExpectStatus: http.StatusOK, + } + tests = append(tests, tc) + } + + for _, tc := range tests { + testCaseCheck(tc, t) + } +} diff --git a/api/test/e2enew/consumer/consumer_test.go b/api/test/e2enew/consumer/consumer_test.go index d420f88b57..0b25bccd2e 100644 --- a/api/test/e2enew/consumer/consumer_test.go +++ b/api/test/e2enew/consumer/consumer_test.go @@ -41,7 +41,8 @@ var _ = Describe("Consumer", func() { "count": 2, "time_window": 60, "rejected_code": 503, - "key": "remote_addr" + "key": "remote_addr", + "policy": "local" } }, "desc": "test description" @@ -69,7 +70,8 @@ var _ = Describe("Consumer", func() { "count": 2, "time_window": 60, "rejected_code": 504, - "key": "remote_addr" + "key": "remote_addr", + "policy": "local" } }, "desc": "test description" @@ -111,7 +113,8 @@ var _ = Describe("Consumer", func() { "count": 2, "time_window": 60, "rejected_code": 503, - "key": "remote_addr" + "key": "remote_addr", + "policy": "local" } }, "desc": "test description" diff --git a/api/test/e2enew/go.mod b/api/test/e2enew/go.mod index 94fdd6546e..0d6c7fb642 100644 --- a/api/test/e2enew/go.mod +++ b/api/test/e2enew/go.mod @@ -9,35 +9,3 @@ require ( github.com/stretchr/testify v1.7.0 github.com/tidwall/gjson v1.11.0 ) - -require ( - github.com/ajg/form v1.5.1 // indirect - github.com/andybalholm/brotli v1.0.2 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/fatih/structs v1.0.0 // indirect - github.com/fsnotify/fsnotify v1.4.9 // indirect - github.com/google/go-querystring v1.0.0 // indirect - github.com/gorilla/websocket v1.4.2 // indirect - github.com/imkira/go-interpol v1.0.0 // indirect - github.com/klauspost/compress v1.12.2 // indirect - github.com/nxadm/tail v1.4.8 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/sergi/go-diff v1.0.0 // indirect - github.com/tidwall/match v1.1.1 // indirect - github.com/tidwall/pretty v1.2.0 // indirect - github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasthttp v1.27.0 // indirect - github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect - github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect - github.com/xeipuuv/gojsonschema v1.1.0 // indirect - github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0 // indirect - github.com/yudai/gojsondiff v1.0.0 // indirect - github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect - golang.org/x/net v0.0.0-20210510120150-4163338589ed // indirect - golang.org/x/sys v0.0.0-20210514084401-e8d321eab015 // indirect - golang.org/x/text v0.3.6 // indirect - gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect - gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect - moul.io/http2curl v1.0.1-0.20190925090545-5cd742060b0e // indirect -) diff --git a/api/test/e2enew/go.sum b/api/test/e2enew/go.sum index b0dbf1baf7..b6d85c7607 100644 --- a/api/test/e2enew/go.sum +++ b/api/test/e2enew/go.sum @@ -14,7 +14,6 @@ github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWo github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/gavv/httpexpect/v2 v2.3.1 h1:sGLlKMn8AuHS9ztK9Sb7AJ7OxIL8v2PcLdyxfKt1Fo4= github.com/gavv/httpexpect/v2 v2.3.1/go.mod h1:yOE8m/aqFYQDNrgprMeXgq4YynfN9h1NgcE1+1suV64= -github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= @@ -137,7 +136,6 @@ golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e h1:4nW4NLDYnU28ojHaHO8OVxFHk/aQ33U01a9cjED+pzE= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/api/test/e2enew/id_compatible/id_compatible_suite_test.go b/api/test/e2enew/id_compatible/id_compatible_suite_test.go new file mode 100644 index 0000000000..fa15893877 --- /dev/null +++ b/api/test/e2enew/id_compatible/id_compatible_suite_test.go @@ -0,0 +1,39 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package id_compatible_test + +import ( + "testing" + "time" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + "github.com/apisix/manager-api/test/e2enew/base" +) + +func TestIdCompatible(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Id Compatible Suite") +} + +var _ = AfterSuite(func() { + base.CleanResource("routes") + base.CleanResource("upstreams") + base.CleanResource("services") + time.Sleep(base.SleepTime) +}) diff --git a/api/test/e2enew/id_compatible/id_crossing_test.go b/api/test/e2enew/id_compatible/id_crossing_test.go new file mode 100644 index 0000000000..be4561f567 --- /dev/null +++ b/api/test/e2enew/id_compatible/id_crossing_test.go @@ -0,0 +1,116 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package id_compatible_test + +import ( + "net/http" + + . "github.com/onsi/ginkgo/extensions/table" + + "github.com/apisix/manager-api/test/e2enew/base" +) + +var _ = DescribeTable("Id Crossing", + func(tc base.HttpTestCase) { + base.RunTestCase(tc) + }, + Entry("create upstream by admin api", base.HttpTestCase{ + Object: base.APISIXExpect(), + Method: http.MethodPut, + Path: "/apisix/admin/upstreams", + Body: `{ + "id": 3, + "nodes": [{ + "host": "` + base.UpstreamIp + `", + "port": 1980, + "weight": 1 + }], + "type": "roundrobin" + }`, + Headers: map[string]string{"X-API-KEY": "edd1c9f034335f136f87ad84b625c8f1"}, + ExpectStatus: http.StatusCreated, + }), + Entry("create route by admin api", base.HttpTestCase{ + Object: base.APISIXExpect(), + Method: http.MethodPut, + Path: "/apisix/admin/routes/3", + Body: `{ + "name": "route3", + "uri": "/hello", + "upstream_id": 3 + }`, + Headers: map[string]string{"X-API-KEY": "edd1c9f034335f136f87ad84b625c8f1"}, + ExpectStatus: http.StatusCreated, + Sleep: base.SleepTime, + }), + Entry("verify that the upstream is available for manager api", base.HttpTestCase{ + Object: base.ManagerApiExpect(), + Method: http.MethodGet, + Path: "/apisix/admin/upstreams/3", + Headers: map[string]string{"Authorization": base.GetToken()}, + ExpectStatus: http.StatusOK, + ExpectBody: `"id":3`, + Sleep: base.SleepTime, + }), + Entry("verify that the route is available for manager api", base.HttpTestCase{ + Object: base.ManagerApiExpect(), + Method: http.MethodGet, + Path: "/apisix/admin/routes/3", + Headers: map[string]string{"Authorization": base.GetToken()}, + ExpectStatus: http.StatusOK, + ExpectBody: `"upstream_id":3`, + Sleep: base.SleepTime, + }), + Entry("hit the route just created", base.HttpTestCase{ + Object: base.APISIXExpect(), + Method: http.MethodGet, + Path: "/hello", + ExpectStatus: http.StatusOK, + ExpectBody: "hello world", + Sleep: base.SleepTime, + }), + Entry("delete the route", base.HttpTestCase{ + Object: base.ManagerApiExpect(), + Method: http.MethodDelete, + Path: "/apisix/admin/routes/3", + Headers: map[string]string{"Authorization": base.GetToken()}, + ExpectStatus: http.StatusOK, + }), + Entry("delete the upstream", base.HttpTestCase{ + Object: base.ManagerApiExpect(), + Method: http.MethodDelete, + Path: "/apisix/admin/upstreams/3", + Headers: map[string]string{"Authorization": base.GetToken()}, + ExpectStatus: http.StatusOK, + Sleep: base.SleepTime, + }), + Entry("make sure the upstream has been deleted", base.HttpTestCase{ + Object: base.ManagerApiExpect(), + Method: http.MethodGet, + Path: "/apisix/admin/upstreams/3", + Headers: map[string]string{"Authorization": base.GetToken()}, + ExpectStatus: http.StatusNotFound, + Sleep: base.SleepTime, + }), + Entry("hit deleted route", base.HttpTestCase{ + Object: base.APISIXExpect(), + Method: http.MethodGet, + Path: "/hello", + ExpectStatus: http.StatusNotFound, + Sleep: base.SleepTime, + }), +) diff --git a/api/test/e2enew/id_compatible/id_not_in_body_test.go b/api/test/e2enew/id_compatible/id_not_in_body_test.go new file mode 100644 index 0000000000..8227c769f3 --- /dev/null +++ b/api/test/e2enew/id_compatible/id_not_in_body_test.go @@ -0,0 +1,164 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package id_compatible_test + +import ( + "io/ioutil" + "net/http" + "time" + + . "github.com/onsi/ginkgo/extensions/table" + . "github.com/onsi/gomega" + "github.com/tidwall/gjson" + + "github.com/apisix/manager-api/test/e2enew/base" +) + +var _ = DescribeTable("Id Not In Body", + func(f func()) { + f() + }, + Entry("make sure the route is not created", func() { + base.RunTestCase(base.HttpTestCase{ + Object: base.APISIXExpect(), + Method: http.MethodGet, + Path: "/hello", + ExpectStatus: http.StatusNotFound, + Sleep: base.SleepTime, + }) + }), + Entry("create route that has no ID in request body by admin api", func() { + base.RunTestCase(base.HttpTestCase{ + Object: base.APISIXExpect(), + Method: http.MethodPut, + Path: "/apisix/admin/routes/r1", + Body: `{ + "name": "route1", + "uri": "/hello", + "upstream": { + "type": "roundrobin", + "nodes": { + "` + base.UpstreamIp + `:1980": 1 + } + } + }`, + Headers: map[string]string{"X-API-KEY": "edd1c9f034335f136f87ad84b625c8f1"}, + ExpectStatus: http.StatusCreated, + Sleep: base.SleepTime, + }) + }), + Entry("verify that the route is available for manager api", func() { + base.RunTestCase(base.HttpTestCase{ + Object: base.ManagerApiExpect(), + Method: http.MethodGet, + Path: "/apisix/admin/routes/r1", + Headers: map[string]string{"Authorization": base.GetToken()}, + ExpectStatus: http.StatusOK, + ExpectBody: `"id":"r1"`, + Sleep: base.SleepTime, + }) + }), + Entry("hit the route just created", func() { + base.RunTestCase(base.HttpTestCase{ + Object: base.APISIXExpect(), + Method: http.MethodGet, + Path: "/hello", + ExpectStatus: http.StatusOK, + ExpectBody: "hello world", + Sleep: base.SleepTime, + }) + }), + Entry("delete the route", func() { + base.RunTestCase(base.HttpTestCase{ + Object: base.ManagerApiExpect(), + Method: http.MethodDelete, + Path: "/apisix/admin/routes/r1", + Headers: map[string]string{"Authorization": base.GetToken()}, + ExpectStatus: http.StatusOK, + }) + }), + Entry("hit deleted route", func() { + base.RunTestCase(base.HttpTestCase{ + Object: base.APISIXExpect(), + Method: http.MethodGet, + Path: "/hello", + ExpectStatus: http.StatusNotFound, + Sleep: base.SleepTime, + }) + }), + Entry("create route that has no ID in request body by admin api (POST)", func() { + base.RunTestCase(base.HttpTestCase{ + Object: base.APISIXExpect(), + Method: http.MethodPost, + Path: "/apisix/admin/routes", + Body: `{ + "uri": "/hello", + "upstream": { + "type": "roundrobin", + "nodes": { + "` + base.UpstreamIp + `:1980": 1 + } + } + }`, + Headers: map[string]string{"X-API-KEY": "edd1c9f034335f136f87ad84b625c8f1"}, + ExpectStatus: http.StatusCreated, + Sleep: base.SleepTime, + }) + }), + Entry("verify that the route is available for manager api", func() { + base.RunTestCase(base.HttpTestCase{ + Object: base.ManagerApiExpect(), + Method: http.MethodGet, + Path: "/apisix/admin/routes", + Headers: map[string]string{"Authorization": base.GetToken()}, + ExpectStatus: http.StatusOK, + ExpectBody: `"uri":"/hello"`, + Sleep: base.SleepTime, + }) + }), + Entry("hit the route just created", func() { + base.RunTestCase(base.HttpTestCase{ + Object: base.APISIXExpect(), + Method: http.MethodGet, + Path: "/hello", + ExpectStatus: http.StatusOK, + ExpectBody: "hello world", + Sleep: base.SleepTime, + }) + }), + Entry("clear the route", func() { + time.Sleep(time.Duration(100) * time.Millisecond) + request, _ := http.NewRequest("GET", base.ManagerAPIHost+"/apisix/admin/routes", nil) + request.Header.Add("Authorization", base.GetToken()) + resp, err := http.DefaultClient.Do(request) + Expect(err).To(BeNil()) + defer resp.Body.Close() + respBody, _ := ioutil.ReadAll(resp.Body) + list := gjson.Get(string(respBody), "data.rows").Value().([]interface{}) + for _, item := range list { + route := item.(map[string]interface{}) + base.RunTestCase(base.HttpTestCase{ + Desc: "delete the route", + Object: base.ManagerApiExpect(), + Method: http.MethodDelete, + Path: "/apisix/admin/routes/" + route["id"].(string), + Headers: map[string]string{"Authorization": base.GetToken()}, + ExpectStatus: http.StatusOK, + }) + } + }), +) diff --git a/api/test/e2enew/id_compatible/id_using_int_test.go b/api/test/e2enew/id_compatible/id_using_int_test.go new file mode 100644 index 0000000000..779ccb3caf --- /dev/null +++ b/api/test/e2enew/id_compatible/id_using_int_test.go @@ -0,0 +1,144 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package id_compatible_test + +import ( + "net/http" + + . "github.com/onsi/ginkgo/extensions/table" + + "github.com/apisix/manager-api/test/e2enew/base" +) + +var _ = DescribeTable("Id Using Int", + func(tc base.HttpTestCase) { + base.RunTestCase(tc) + }, + Entry("create upstream", base.HttpTestCase{ + Object: base.ManagerApiExpect(), + Method: http.MethodPut, + Path: "/apisix/admin/upstreams", + Body: `{ + "id": 1, + "nodes": [{ + "host": "` + base.UpstreamIp + `", + "port": 1980, + "weight": 1 + }], + "type": "roundrobin" + }`, + Headers: map[string]string{"Authorization": base.GetToken()}, + ExpectStatus: http.StatusOK, + }), + Entry("create route using the upstream just created", base.HttpTestCase{ + Object: base.ManagerApiExpect(), + Method: http.MethodPut, + Path: "/apisix/admin/routes/1", + Body: `{ + "name": "route1", + "uri": "/hello", + "upstream_id": 1 + }`, + Headers: map[string]string{"Authorization": base.GetToken()}, + ExpectStatus: http.StatusOK, + Sleep: base.SleepTime, + }), + Entry("hit the route just created", base.HttpTestCase{ + Object: base.APISIXExpect(), + Method: http.MethodGet, + Path: "/hello", + ExpectStatus: http.StatusOK, + ExpectBody: "hello world", + Sleep: base.SleepTime, + }), + Entry("create service", base.HttpTestCase{ + Object: base.ManagerApiExpect(), + Method: http.MethodPut, + Path: "/apisix/admin/services", + Body: `{ + "id": 1, + "upstream_id": 1 + }`, + Headers: map[string]string{"Authorization": base.GetToken()}, + ExpectStatus: http.StatusOK, + }), + Entry("update route to use the service just created", base.HttpTestCase{ + Object: base.ManagerApiExpect(), + Method: http.MethodPut, + Path: "/apisix/admin/routes/1", + Body: `{ + "name": "route1", + "uri": "/hello", + "service_id": 1 + }`, + Headers: map[string]string{"Authorization": base.GetToken()}, + ExpectStatus: http.StatusOK, + Sleep: base.SleepTime, + }), + Entry("hit the route just updated", base.HttpTestCase{ + Object: base.APISIXExpect(), + Method: http.MethodGet, + Path: "/hello", + ExpectStatus: http.StatusOK, + ExpectBody: "hello world", + Sleep: base.SleepTime, + }), + Entry("delete the route", base.HttpTestCase{ + Object: base.ManagerApiExpect(), + Method: http.MethodDelete, + Path: "/apisix/admin/routes/1", + Headers: map[string]string{"Authorization": base.GetToken()}, + ExpectStatus: http.StatusOK, + }), + Entry("delete the service", base.HttpTestCase{ + Object: base.ManagerApiExpect(), + Method: http.MethodDelete, + Path: "/apisix/admin/services/1", + Headers: map[string]string{"Authorization": base.GetToken()}, + ExpectStatus: http.StatusOK, + Sleep: base.SleepTime, + }), + Entry("make sure the service has been deleted", base.HttpTestCase{ + Object: base.ManagerApiExpect(), + Method: http.MethodGet, + Path: "/apisix/admin/services/1", + Headers: map[string]string{"Authorization": base.GetToken()}, + ExpectStatus: http.StatusNotFound, + }), + Entry("delete the upstream", base.HttpTestCase{ + Object: base.ManagerApiExpect(), + Method: http.MethodDelete, + Path: "/apisix/admin/upstreams/1", + Headers: map[string]string{"Authorization": base.GetToken()}, + ExpectStatus: http.StatusOK, + }), + Entry("make sure the upstream has been deleted", base.HttpTestCase{ + Object: base.ManagerApiExpect(), + Method: http.MethodGet, + Path: "/apisix/admin/upstreams/1", + Headers: map[string]string{"Authorization": base.GetToken()}, + ExpectStatus: http.StatusNotFound, + Sleep: base.SleepTime, + }), + Entry("hit deleted route", base.HttpTestCase{ + Object: base.APISIXExpect(), + Method: http.MethodGet, + Path: "/hello", + ExpectStatus: http.StatusNotFound, + Sleep: base.SleepTime, + }), +) diff --git a/api/test/e2enew/id_compatible/id_using_string_test.go b/api/test/e2enew/id_compatible/id_using_string_test.go new file mode 100644 index 0000000000..54bb30eea5 --- /dev/null +++ b/api/test/e2enew/id_compatible/id_using_string_test.go @@ -0,0 +1,99 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package id_compatible_test + +import ( + "net/http" + + . "github.com/onsi/ginkgo/extensions/table" + + "github.com/apisix/manager-api/test/e2enew/base" +) + +var _ = DescribeTable("Id Using String", + func(tc base.HttpTestCase) { + base.RunTestCase(tc) + }, + Entry("create upstream", base.HttpTestCase{ + Object: base.ManagerApiExpect(), + Method: http.MethodPut, + Path: "/apisix/admin/upstreams", + Body: `{ + "id": "2", + "nodes": [{ + "host": "` + base.UpstreamIp + `", + "port": 1980, + "weight": 1 + }], + "type": "roundrobin" + }`, + Headers: map[string]string{"Authorization": base.GetToken()}, + ExpectStatus: http.StatusOK, + }), + Entry("create route using the upstream just created", base.HttpTestCase{ + Object: base.ManagerApiExpect(), + Method: http.MethodPut, + Path: "/apisix/admin/routes/2", + Body: `{ + "name": "route2", + "uri": "/hello", + "upstream_id": "2" + }`, + Headers: map[string]string{"Authorization": base.GetToken()}, + ExpectStatus: http.StatusOK, + Sleep: base.SleepTime, + }), + Entry("hit the route just created", base.HttpTestCase{ + Object: base.APISIXExpect(), + Method: http.MethodGet, + Path: "/hello", + ExpectStatus: http.StatusOK, + ExpectBody: "hello world", + Sleep: base.SleepTime, + }), + Entry("delete the route", base.HttpTestCase{ + Object: base.ManagerApiExpect(), + Method: http.MethodDelete, + Path: "/apisix/admin/routes/2", + Headers: map[string]string{"Authorization": base.GetToken()}, + ExpectStatus: http.StatusOK, + Sleep: base.SleepTime, + }), + Entry("delete the upstream", base.HttpTestCase{ + Object: base.ManagerApiExpect(), + Method: http.MethodDelete, + Path: "/apisix/admin/upstreams/2", + Headers: map[string]string{"Authorization": base.GetToken()}, + ExpectStatus: http.StatusOK, + Sleep: base.SleepTime, + }), + Entry("make sure the upstream has been deleted", base.HttpTestCase{ + Object: base.ManagerApiExpect(), + Method: http.MethodGet, + Path: "/apisix/admin/upstreams/2", + Headers: map[string]string{"Authorization": base.GetToken()}, + ExpectStatus: http.StatusNotFound, + Sleep: base.SleepTime, + }), + Entry("hit deleted route", base.HttpTestCase{ + Object: base.APISIXExpect(), + Method: http.MethodGet, + Path: "/hello", + ExpectStatus: http.StatusNotFound, + Sleep: base.SleepTime, + }), +) diff --git a/api/test/e2enew/label/label_test.go b/api/test/e2enew/label/label_test.go index 12f5900bf5..954f72d846 100644 --- a/api/test/e2enew/label/label_test.go +++ b/api/test/e2enew/label/label_test.go @@ -109,7 +109,8 @@ var _ = ginkgo.Describe("Test label", func() { "count": 2, "time_window": 60, "rejected_code": 503, - "key": "remote_addr" + "key": "remote_addr", + "policy": "local" } }, "upstream": { diff --git a/api/test/e2enew/route/host_test.go b/api/test/e2enew/route/host_test.go index a865003f88..71e141a875 100644 --- a/api/test/e2enew/route/host_test.go +++ b/api/test/e2enew/route/host_test.go @@ -19,18 +19,18 @@ package route import ( "net/http" - "github.com/onsi/ginkgo" - "github.com/onsi/ginkgo/extensions/table" + . "github.com/onsi/ginkgo" + . "github.com/onsi/ginkgo/extensions/table" "github.com/apisix/manager-api/test/e2enew/base" ) -var _ = ginkgo.Describe("Route", func() { - table.DescribeTable("test route with host", +var _ = Describe("Route", func() { + DescribeTable("test route with host", func(tc base.HttpTestCase) { base.RunTestCase(tc) }, - table.Entry("invalid host", base.HttpTestCase{ + Entry("invalid host", base.HttpTestCase{ Object: base.ManagerApiExpect(), Path: "/apisix/admin/routes/r1", Method: http.MethodPut, @@ -48,7 +48,7 @@ var _ = ginkgo.Describe("Route", func() { Headers: map[string]string{"Authorization": base.GetToken()}, ExpectStatus: http.StatusBadRequest, }), - table.Entry("invalid hosts", base.HttpTestCase{ + Entry("invalid hosts", base.HttpTestCase{ Object: base.ManagerApiExpect(), Method: http.MethodPut, Path: "/apisix/admin/routes/r1", @@ -66,7 +66,7 @@ var _ = ginkgo.Describe("Route", func() { Headers: map[string]string{"Authorization": base.GetToken()}, ExpectStatus: http.StatusBadRequest, }), - table.Entry("create route with host and hosts together at the same time", base.HttpTestCase{ + Entry("create route with host and hosts together at the same time", base.HttpTestCase{ Object: base.ManagerApiExpect(), Method: http.MethodPut, Path: "/apisix/admin/routes/r1", @@ -85,7 +85,7 @@ var _ = ginkgo.Describe("Route", func() { Headers: map[string]string{"Authorization": base.GetToken()}, ExpectStatus: http.StatusBadRequest, }), - table.Entry("hit route not created", base.HttpTestCase{ + Entry("hit route not created", base.HttpTestCase{ Object: base.APISIXExpect(), Method: http.MethodGet, Path: "/hello_", @@ -93,7 +93,7 @@ var _ = ginkgo.Describe("Route", func() { ExpectStatus: http.StatusNotFound, ExpectBody: "{\"error_msg\":\"404 Route Not Found\"}\n", }), - table.Entry("hit route not created", base.HttpTestCase{ + Entry("hit route not created", base.HttpTestCase{ Object: base.APISIXExpect(), Method: http.MethodGet, Path: "/hello_", @@ -103,11 +103,11 @@ var _ = ginkgo.Describe("Route", func() { }), ) - table.DescribeTable("test route with hosts", + DescribeTable("test route with hosts", func(tc base.HttpTestCase) { base.RunTestCase(tc) }, - table.Entry("make sure route not created", base.HttpTestCase{ + Entry("make sure route not created", base.HttpTestCase{ Object: base.APISIXExpect(), Method: http.MethodGet, Path: "/hello_", @@ -115,7 +115,7 @@ var _ = ginkgo.Describe("Route", func() { ExpectStatus: http.StatusNotFound, ExpectBody: "{\"error_msg\":\"404 Route Not Found\"}\n", }), - table.Entry("create route", base.HttpTestCase{ + Entry("create route", base.HttpTestCase{ Object: base.ManagerApiExpect(), Method: http.MethodPut, Path: "/apisix/admin/routes/r1", @@ -133,7 +133,7 @@ var _ = ginkgo.Describe("Route", func() { Headers: map[string]string{"Authorization": base.GetToken()}, ExpectStatus: http.StatusOK, }), - table.Entry("create route with int uri", base.HttpTestCase{ + Entry("create route with int uri", base.HttpTestCase{ Object: base.ManagerApiExpect(), Method: http.MethodPut, Path: "/apisix/admin/routes/r1", @@ -144,7 +144,7 @@ var _ = ginkgo.Describe("Route", func() { Headers: map[string]string{"Authorization": base.GetToken()}, ExpectStatus: http.StatusBadRequest, }), - table.Entry("hit the route just created - wildcard domain name", base.HttpTestCase{ + Entry("hit the route just created - wildcard domain name", base.HttpTestCase{ Object: base.APISIXExpect(), Method: http.MethodGet, Path: "/hello_", @@ -153,7 +153,7 @@ var _ = ginkgo.Describe("Route", func() { ExpectBody: "hello world\n", Sleep: base.SleepTime, }), - table.Entry("hit the route just created", base.HttpTestCase{ + Entry("hit the route just created", base.HttpTestCase{ Object: base.APISIXExpect(), Method: http.MethodGet, Path: "/hello_", @@ -161,7 +161,7 @@ var _ = ginkgo.Describe("Route", func() { ExpectStatus: http.StatusOK, ExpectBody: "hello world\n", }), - table.Entry("hit the route not exists", base.HttpTestCase{ + Entry("hit the route not exists", base.HttpTestCase{ Object: base.APISIXExpect(), Method: http.MethodGet, Path: "/hello_111", @@ -169,14 +169,14 @@ var _ = ginkgo.Describe("Route", func() { ExpectStatus: http.StatusNotFound, ExpectBody: "{\"error_msg\":\"404 Route Not Found\"}\n", }), - table.Entry("delete the route just created", base.HttpTestCase{ + Entry("delete the route just created", base.HttpTestCase{ Object: base.ManagerApiExpect(), Method: http.MethodDelete, Path: "/apisix/admin/routes/r1", Headers: map[string]string{"Authorization": base.GetToken()}, ExpectStatus: http.StatusOK, }), - table.Entry("hit the route just deleted", base.HttpTestCase{ + Entry("hit the route just deleted", base.HttpTestCase{ Object: base.APISIXExpect(), Method: http.MethodGet, Path: "/hello_", @@ -187,11 +187,106 @@ var _ = ginkgo.Describe("Route", func() { }), ) - table.DescribeTable("test route with empty array", + DescribeTable("update routes with hosts", func(tc base.HttpTestCase) { base.RunTestCase(tc) }, - table.Entry("create route with empty hosts and host", base.HttpTestCase{ + Entry("hit route that not exist", base.HttpTestCase{ + Object: base.APISIXExpect(), + Method: http.MethodGet, + Path: "/hello", + Headers: map[string]string{"Host": "foo.com"}, + ExpectStatus: http.StatusNotFound, + ExpectBody: "{\"error_msg\":\"404 Route Not Found\"}\n", + }), + Entry("create route with host foo.com", base.HttpTestCase{ + Object: base.ManagerApiExpect(), + Method: http.MethodPut, + Path: "/apisix/admin/routes/r1", + Body: `{ + "name": "route1", + "uri": "/hello", + "methods": ["GET"], + "hosts": ["foo.com"], + "upstream": { + "type": "roundrobin", + "nodes": [{ + "host": "` + base.UpstreamIp + `", + "port": 1980, + "weight": 1 + }] + } + }`, + Headers: map[string]string{"Authorization": base.GetToken()}, + ExpectStatus: http.StatusOK, + ExpectBody: []string{"\"id\":\"r1\"", "\"hosts\":[\"foo.com\"]"}, + }), + Entry("hit the route just create", base.HttpTestCase{ + Object: base.APISIXExpect(), + Method: http.MethodGet, + Path: "/hello", + Headers: map[string]string{"Host": "foo.com"}, + ExpectStatus: http.StatusOK, + Sleep: base.SleepTime, + }), + Entry("update route with host bar.com", base.HttpTestCase{ + Object: base.ManagerApiExpect(), + Method: http.MethodPut, + Path: "/apisix/admin/routes/r1", + Body: `{ + "name": "route1", + "uri": "/hello", + "hosts": ["bar.com"], + "upstream": { + "nodes": { + "` + base.UpstreamIp + `:1980": 1 + }, + "type": "roundrobin" + } + }`, + Headers: map[string]string{"Authorization": base.GetToken()}, + ExpectStatus: http.StatusOK, + ExpectBody: []string{"\"id\":\"r1\"", "\"hosts\":[\"bar.com\"]"}, + }), + Entry("hit the route with host foo.com", base.HttpTestCase{ + Object: base.APISIXExpect(), + Method: http.MethodGet, + Path: "/hello", + Headers: map[string]string{"Host": "foo.com"}, + ExpectStatus: http.StatusNotFound, + Sleep: base.SleepTime, + }), + Entry("hit the route just updated", base.HttpTestCase{ + Object: base.APISIXExpect(), + Method: http.MethodGet, + Path: "/hello", + Headers: map[string]string{"Host": "bar.com"}, + ExpectStatus: http.StatusOK, + ExpectBody: "hello world\n", + }), + Entry("delete route", base.HttpTestCase{ + Object: base.ManagerApiExpect(), + Method: http.MethodDelete, + Path: "/apisix/admin/routes/r1", + Headers: map[string]string{"Authorization": base.GetToken()}, + ExpectStatus: http.StatusOK, + }), + Entry("hit the route just deleted", base.HttpTestCase{ + Object: base.APISIXExpect(), + Method: http.MethodGet, + Path: "/hello", + Headers: map[string]string{"Host": "bar.com"}, + ExpectStatus: http.StatusNotFound, + ExpectBody: "{\"error_msg\":\"404 Route Not Found\"}\n", + Sleep: base.SleepTime, + }), + ) + + DescribeTable("test route with empty array", + func(tc base.HttpTestCase) { + base.RunTestCase(tc) + }, + Entry("create route with empty hosts and host", base.HttpTestCase{ Object: base.ManagerApiExpect(), Path: "/apisix/admin/routes/r1", Method: http.MethodPut, @@ -211,7 +306,7 @@ var _ = ginkgo.Describe("Route", func() { ExpectStatus: http.StatusBadRequest, ExpectBody: `{"code":10000,"message":"schema validate failed: (root): Must validate one and only one schema (oneOf)\n(root): Must validate all the schemas (allOf)\nhosts: Array must have at least 1 items"}`, }), - table.Entry("make sure the route not created", base.HttpTestCase{ + Entry("make sure the route not created", base.HttpTestCase{ Object: base.APISIXExpect(), Method: http.MethodGet, Path: "/hello", @@ -219,7 +314,7 @@ var _ = ginkgo.Describe("Route", func() { ExpectStatus: http.StatusNotFound, ExpectBody: `{"error_msg":"404 Route Not Found"}`, }), - table.Entry("create route with empty hosts", base.HttpTestCase{ + Entry("create route with empty hosts", base.HttpTestCase{ Object: base.ManagerApiExpect(), Path: "/apisix/admin/routes/r1", Method: http.MethodPut, @@ -238,14 +333,14 @@ var _ = ginkgo.Describe("Route", func() { ExpectStatus: http.StatusBadRequest, ExpectBody: `{"code":10000,"message":"schema validate failed: hosts: Array must have at least 1 items"}`, }), - table.Entry("make sure the route not created", base.HttpTestCase{ + Entry("make sure the route not created", base.HttpTestCase{ Object: base.APISIXExpect(), Method: http.MethodGet, Path: "/hello", ExpectStatus: http.StatusNotFound, ExpectBody: `{"error_msg":"404 Route Not Found"}`, }), - table.Entry("create route with empty uris and uri", base.HttpTestCase{ + Entry("create route with empty uris and uri", base.HttpTestCase{ Object: base.ManagerApiExpect(), Path: "/apisix/admin/routes/r1", Method: http.MethodPut, @@ -264,7 +359,7 @@ var _ = ginkgo.Describe("Route", func() { ExpectStatus: http.StatusBadRequest, ExpectBody: `{"code":10000,"message":"schema validate failed: (root): Must validate one and only one schema (oneOf)\n(root): Must validate all the schemas (allOf)\nuris: Array must have at least 1 items"}`, }), - table.Entry("create route with empty remote_addrs and remote_addr", base.HttpTestCase{ + Entry("create route with empty remote_addrs and remote_addr", base.HttpTestCase{ Object: base.ManagerApiExpect(), Path: "/apisix/admin/routes/r1", Method: http.MethodPut, @@ -284,7 +379,7 @@ var _ = ginkgo.Describe("Route", func() { ExpectStatus: http.StatusBadRequest, ExpectBody: `{"code":10000,"message":"schema validate failed: (root): Must validate one and only one schema (oneOf)\n(root): Must validate all the schemas (allOf)\nremote_addrs: Array must have at least 1 items"}`, }), - table.Entry("make sure the route not created", base.HttpTestCase{ + Entry("make sure the route not created", base.HttpTestCase{ Object: base.APISIXExpect(), Method: http.MethodGet, Path: "/hello", diff --git a/api/test/e2enew/route/route_export_test.go b/api/test/e2enew/route/route_export_test.go index 2497f98e4e..de9923cc11 100644 --- a/api/test/e2enew/route/route_export_test.go +++ b/api/test/e2enew/route/route_export_test.go @@ -56,6 +56,7 @@ var _ = ginkgo.Describe("Route", func() { "security": [], "x-apisix-enable_websocket": false, "x-apisix-hosts": ["foo.com", "*.bar.com"], + "x-apisix-id":"r1", "x-apisix-labels": { "build": "16", "env": "production", @@ -65,6 +66,7 @@ var _ = ginkgo.Describe("Route", func() { "limit-count": { "count": 2, "key": "remote_addr", + "policy": "local", "rejected_code": 503, "time_window": 60 } @@ -89,6 +91,7 @@ var _ = ginkgo.Describe("Route", func() { "security": [], "x-apisix-enable_websocket": false, "x-apisix-hosts": ["foo.com", "*.bar.com"], + "x-apisix-id":"r1", "x-apisix-labels": { "build": "16", "env": "production", @@ -98,6 +101,7 @@ var _ = ginkgo.Describe("Route", func() { "limit-count": { "count": 2, "key": "remote_addr", + "policy": "local", "rejected_code": 503, "time_window": 60 } @@ -140,7 +144,8 @@ var _ = ginkgo.Describe("Route", func() { "count": 2, "time_window": 60, "rejected_code": 503, - "key": "remote_addr" + "key": "remote_addr", + "policy": "local" } }, "status": 1, @@ -183,6 +188,7 @@ var _ = ginkgo.Describe("Route", func() { "security": [], "x-apisix-enable_websocket": false, "x-apisix-host": "*.bar.com", + "x-apisix-id":"r2", "x-apisix-labels": { "build": "16", "env": "production", @@ -192,6 +198,7 @@ var _ = ginkgo.Describe("Route", func() { "limit-count": { "count": 2, "key": "remote_addr", + "policy": "local", "rejected_code": 503, "time_window": 60 } @@ -216,6 +223,7 @@ var _ = ginkgo.Describe("Route", func() { "security": [], "x-apisix-enable_websocket": false, "x-apisix-host": "*.bar.com", + "x-apisix-id":"r2", "x-apisix-labels": { "build": "16", "env": "production", @@ -225,6 +233,7 @@ var _ = ginkgo.Describe("Route", func() { "limit-count": { "count": 2, "key": "remote_addr", + "policy": "local", "rejected_code": 503, "time_window": 60 } @@ -268,7 +277,8 @@ var _ = ginkgo.Describe("Route", func() { "count": 2, "time_window": 60, "rejected_code": 503, - "key": "remote_addr" + "key": "remote_addr", + "policy": "local" } }, "status": 1, @@ -372,6 +382,7 @@ var _ = ginkgo.Describe("Route", func() { "limit-count": { "count": 100, "key": "remote_addr", + "policy": "local", "rejected_code": 503, "time_window": 60 } @@ -403,6 +414,7 @@ var _ = ginkgo.Describe("Route", func() { } }, "x-apisix-enable_websocket": false, + "x-apisix-id":"r3", "x-apisix-labels": { "build": "16", "env": "production", @@ -412,6 +424,7 @@ var _ = ginkgo.Describe("Route", func() { "limit-count": { "count": 100, "key": "remote_addr", + "policy": "local", "rejected_code": 503, "time_window": 60 } @@ -450,6 +463,7 @@ var _ = ginkgo.Describe("Route", func() { "limit-count": { "count": 100, "time_window": 60, + "policy": "local", "rejected_code": 503, "key": "remote_addr" } @@ -552,6 +566,7 @@ var _ = ginkgo.Describe("Route", func() { "limit-count": { "count": 100, "key": "remote_addr", + "policy": "local", "rejected_code": 503, "time_window": 60 } @@ -583,6 +598,7 @@ var _ = ginkgo.Describe("Route", func() { }, "security": [], "x-apisix-enable_websocket": false, + "x-apisix-id":"r4", "x-apisix-labels": { "build": "16", "env": "production", @@ -592,6 +608,7 @@ var _ = ginkgo.Describe("Route", func() { "limit-count": { "count": 100, "key": "remote_addr", + "policy": "local", "rejected_code": 503, "time_window": 60 }, @@ -634,7 +651,8 @@ var _ = ginkgo.Describe("Route", func() { "count": 100, "time_window": 60, "rejected_code": 503, - "key": "remote_addr" + "key": "remote_addr", + "policy": "local" } }, "upstream": { @@ -740,6 +758,7 @@ var _ = ginkgo.Describe("Route", func() { "limit-count": { "count": 100, "key": "remote_addr", + "policy": "local", "rejected_code": 503, "time_window": 60 } @@ -771,6 +790,7 @@ var _ = ginkgo.Describe("Route", func() { }, "security": [], "x-apisix-enable_websocket": false, + "x-apisix-id":"r5", "x-apisix-labels": { "build": "16", "env": "production", @@ -780,6 +800,7 @@ var _ = ginkgo.Describe("Route", func() { "limit-count": { "count": 100, "key": "remote_addr", + "policy": "local", "rejected_code": 503, "time_window": 60 }, @@ -840,7 +861,8 @@ var _ = ginkgo.Describe("Route", func() { "count": 100, "time_window": 60, "rejected_code": 503, - "key": "remote_addr" + "key": "remote_addr", + "policy": "local" } }, "upstream_id": "1" @@ -955,6 +977,7 @@ var _ = ginkgo.Describe("Route", func() { }, "security": [], "x-apisix-enable_websocket": false, + "x-apisix-id":"r8", "x-apisix-plugins": { "prometheus": { "disable": false @@ -1077,6 +1100,7 @@ var _ = ginkgo.Describe("Route", func() { "security": [], "summary": "所有", "x-apisix-enable_websocket": false, + "x-apisix-id":"r9", "x-apisix-labels": { "API_VERSION": "v1", "test": "1" @@ -1276,6 +1300,7 @@ var _ = ginkgo.Describe("Route", func() { "security": [], "summary": "所有", "x-apisix-enable_websocket": false, + "x-apisix-id":"r10", "x-apisix-labels": { "API_VERSION": "v1", "test": "1" @@ -1901,6 +1926,7 @@ var _ = ginkgo.Describe("Route", func() { }, "summary": "所有", "x-apisix-enable_websocket": false, + "x-apisix-id":"r1", "x-apisix-labels": { "build": "16", "env": "production", @@ -2043,6 +2069,7 @@ var _ = ginkgo.Describe("Route", func() { }, "summary": "所有", "x-apisix-enable_websocket": false, + "x-apisix-id":"r2", "x-apisix-labels": { "build": "16", "env": "production", @@ -2197,6 +2224,7 @@ var _ = ginkgo.Describe("Route", func() { "summary": "所有", "x-apisix-enable_websocket": false, "x-apisix-hosts": ["test.com"], + "x-apisix-id":"r1", "x-apisix-priority": 0, "x-apisix-status": 1 } @@ -2300,6 +2328,7 @@ var _ = ginkgo.Describe("Route", func() { "summary": "所有", "x-apisix-enable_websocket": false, "x-apisix-hosts": ["test.com"], + "x-apisix-id":"r1", "x-apisix-priority": 0, "x-apisix-status": 1, "x-apisix-upstream": { @@ -2322,6 +2351,7 @@ var _ = ginkgo.Describe("Route", func() { "summary": "所有1", "x-apisix-enable_websocket": false, "x-apisix-hosts": ["test.com"], + "x-apisix-id":"r2", "x-apisix-priority": 0, "x-apisix-status": 1, "x-apisix-upstream": { @@ -2344,6 +2374,7 @@ var _ = ginkgo.Describe("Route", func() { "summary": "所有2", "x-apisix-enable_websocket": false, "x-apisix-hosts": ["test.com"], + "x-apisix-id":"r3", "x-apisix-priority": 0, "x-apisix-status": 1, "x-apisix-upstream": { diff --git a/api/test/e2enew/route/route_test.go b/api/test/e2enew/route/route_test.go index 9a774593cf..acba6cb0db 100644 --- a/api/test/e2enew/route/route_test.go +++ b/api/test/e2enew/route/route_test.go @@ -19,18 +19,18 @@ package route import ( "net/http" - "github.com/onsi/ginkgo" - "github.com/onsi/ginkgo/extensions/table" + . "github.com/onsi/ginkgo" + . "github.com/onsi/ginkgo/extensions/table" "github.com/apisix/manager-api/test/e2enew/base" ) -var _ = ginkgo.Describe("Route", func() { - table.DescribeTable("test route create and update", +var _ = Describe("Route", func() { + DescribeTable("test route create and update", func(tc base.HttpTestCase) { base.RunTestCase(tc) }, - table.Entry("create route1 success", base.HttpTestCase{ + Entry("create route1 success", base.HttpTestCase{ Object: base.ManagerApiExpect(), Method: http.MethodPut, Path: "/apisix/admin/routes/r1", @@ -47,7 +47,7 @@ var _ = ginkgo.Describe("Route", func() { Headers: map[string]string{"Authorization": base.GetToken()}, ExpectStatus: http.StatusOK, }), - table.Entry("create route2 success", base.HttpTestCase{ + Entry("create route2 success", base.HttpTestCase{ Object: base.ManagerApiExpect(), Method: http.MethodPut, Path: "/apisix/admin/routes/r2", @@ -64,7 +64,7 @@ var _ = ginkgo.Describe("Route", func() { Headers: map[string]string{"Authorization": base.GetToken()}, ExpectStatus: http.StatusOK, }), - table.Entry("create route failed, name existed", base.HttpTestCase{ + Entry("create route failed, name existed", base.HttpTestCase{ Object: base.ManagerApiExpect(), Method: http.MethodPost, Path: "/apisix/admin/routes", @@ -83,7 +83,7 @@ var _ = ginkgo.Describe("Route", func() { ExpectBody: `route name exists`, Sleep: base.SleepTime, }), - table.Entry("update route2 failed, name existed", base.HttpTestCase{ + Entry("update route2 failed, name existed", base.HttpTestCase{ Object: base.ManagerApiExpect(), Method: http.MethodPut, Path: "/apisix/admin/routes/r2", @@ -101,7 +101,7 @@ var _ = ginkgo.Describe("Route", func() { ExpectStatus: http.StatusBadRequest, ExpectBody: `route name exists`, }), - table.Entry("update route2 success, name not change", base.HttpTestCase{ + Entry("update route2 success, name not change", base.HttpTestCase{ Object: base.ManagerApiExpect(), Method: http.MethodPut, Path: "/apisix/admin/routes/r2", @@ -118,7 +118,7 @@ var _ = ginkgo.Describe("Route", func() { Headers: map[string]string{"Authorization": base.GetToken()}, ExpectStatus: http.StatusOK, }), - table.Entry("hit route1", base.HttpTestCase{ + Entry("hit route1", base.HttpTestCase{ Object: base.APISIXExpect(), Method: http.MethodGet, Path: "/hello_", @@ -126,28 +126,28 @@ var _ = ginkgo.Describe("Route", func() { ExpectBody: "hello world", Sleep: base.SleepTime, }), - table.Entry("hit route2", base.HttpTestCase{ + Entry("hit route2", base.HttpTestCase{ Object: base.APISIXExpect(), Method: http.MethodGet, Path: "/hello", ExpectStatus: http.StatusOK, ExpectBody: "hello world", }), - table.Entry("delete route1", base.HttpTestCase{ + Entry("delete route1", base.HttpTestCase{ Object: base.ManagerApiExpect(), Method: http.MethodDelete, Path: "/apisix/admin/routes/r1", Headers: map[string]string{"Authorization": base.GetToken()}, ExpectStatus: http.StatusOK, }), - table.Entry("delete route2", base.HttpTestCase{ + Entry("delete route2", base.HttpTestCase{ Object: base.ManagerApiExpect(), Method: http.MethodDelete, Path: "/apisix/admin/routes/r2", Headers: map[string]string{"Authorization": base.GetToken()}, ExpectStatus: http.StatusOK, }), - table.Entry("hit route1 that just deleted", base.HttpTestCase{ + Entry("hit route1 that just deleted", base.HttpTestCase{ Object: base.APISIXExpect(), Method: http.MethodGet, Path: "/hello_", @@ -155,7 +155,7 @@ var _ = ginkgo.Describe("Route", func() { ExpectBody: "{\"error_msg\":\"404 Route Not Found\"}", Sleep: base.SleepTime, }), - table.Entry("hit route2 that just deleted", base.HttpTestCase{ + Entry("hit route2 that just deleted", base.HttpTestCase{ Object: base.APISIXExpect(), Method: http.MethodGet, Path: "/hello", @@ -164,4 +164,150 @@ var _ = ginkgo.Describe("Route", func() { Sleep: base.SleepTime, }), ) + + DescribeTable("test route patch", + func(tc base.HttpTestCase) { + base.RunTestCase(tc) + }, + Entry("make sure the route not exists", base.HttpTestCase{ + Object: base.APISIXExpect(), + Method: http.MethodGet, + Path: "/hello", + ExpectStatus: http.StatusNotFound, + ExpectBody: "{\"error_msg\":\"404 Route Not Found\"}\n", + }), + Entry("create route", base.HttpTestCase{ + Object: base.ManagerApiExpect(), + Method: http.MethodPut, + Path: "/apisix/admin/routes/r1", + Body: `{ + "name": "route1", + "uri": "/hello", + "upstream": { + "nodes": { + "` + base.UpstreamIp + `:1980": 1 + }, + "type": "roundrobin" + } + }`, + Headers: map[string]string{"Authorization": base.GetToken()}, + ExpectStatus: http.StatusOK, + }), + Entry("hit the route just created", base.HttpTestCase{ + Object: base.APISIXExpect(), + Method: http.MethodGet, + Path: "/hello", + ExpectStatus: http.StatusOK, + ExpectBody: "hello world", + Sleep: base.SleepTime, + }), + Entry("route patch for update status(route offline)", base.HttpTestCase{ + Object: base.ManagerApiExpect(), + Method: http.MethodPatch, + Path: "/apisix/admin/routes/r1", + Body: `{"status":0}`, + Headers: map[string]string{"Authorization": base.GetToken()}, + ExpectStatus: http.StatusOK, + }), + Entry("make sure the route has been offline", base.HttpTestCase{ + Object: base.APISIXExpect(), + Method: http.MethodGet, + Path: "/hello", + ExpectStatus: http.StatusNotFound, + ExpectBody: "{\"error_msg\":\"404 Route Not Found\"}\n", + Sleep: base.SleepTime, + }), + Entry("route patch for update status (route online)", base.HttpTestCase{ + Object: base.ManagerApiExpect(), + Method: http.MethodPatch, + Path: "/apisix/admin/routes/r1/status", + Body: "1", + Headers: map[string]string{ + "Authorization": base.GetToken(), + "Content-Type": "text/plain", + }, + ExpectStatus: http.StatusOK, + }), + Entry("make sure the route has been online", base.HttpTestCase{ + Object: base.APISIXExpect(), + Method: http.MethodGet, + Path: "/hello", + ExpectStatus: http.StatusOK, + ExpectBody: "hello world", + Sleep: base.SleepTime, + }), + Entry("delete route", base.HttpTestCase{ + Object: base.ManagerApiExpect(), + Method: http.MethodDelete, + Path: "/apisix/admin/routes/r1", + Headers: map[string]string{"Authorization": base.GetToken()}, + ExpectStatus: http.StatusOK, + }), + Entry("hit the route just deleted", base.HttpTestCase{ + Object: base.APISIXExpect(), + Method: http.MethodGet, + Path: "/hello", + ExpectStatus: http.StatusNotFound, + ExpectBody: "{\"error_msg\":\"404 Route Not Found\"}\n", + Sleep: base.SleepTime, + }), + ) + + DescribeTable("test route create via POST", + func(tc base.HttpTestCase) { + base.RunTestCase(tc) + }, + Entry("hit route that not exist", base.HttpTestCase{ + Object: base.APISIXExpect(), + Method: http.MethodGet, + Path: "/hello_", + Headers: map[string]string{"Host": "foo.com"}, + ExpectStatus: http.StatusNotFound, + ExpectBody: "{\"error_msg\":\"404 Route Not Found\"}\n", + }), + Entry("create route via HTTP POST", base.HttpTestCase{ + Object: base.ManagerApiExpect(), + Method: http.MethodPost, + Path: "/apisix/admin/routes", + Body: `{ + "id": "r1", + "name": "route1", + "uri": "/hello_", + "hosts": ["foo.com", "*.bar.com"], + "upstream": { + "nodes": { + "` + base.UpstreamIp + `:1980": 1 + }, + "type": "roundrobin" + } + }`, + Headers: map[string]string{"Authorization": base.GetToken()}, + ExpectStatus: http.StatusOK, + ExpectBody: "\"id\":\"r1\"", + }), + Entry("hit the route just created", base.HttpTestCase{ + Object: base.APISIXExpect(), + Method: http.MethodGet, + Path: "/hello_", + Headers: map[string]string{"Host": "foo.com"}, + ExpectStatus: http.StatusOK, + ExpectBody: "hello world\n", + }), + Entry("delete the route just created", base.HttpTestCase{ + Object: base.ManagerApiExpect(), + Method: http.MethodDelete, + Path: "/apisix/admin/routes/r1", + Headers: map[string]string{"Authorization": base.GetToken()}, + ExpectStatus: http.StatusOK, + }), + Entry("hit the route just deleted", base.HttpTestCase{ + Object: base.APISIXExpect(), + Method: http.MethodGet, + Path: "/hello_", + Headers: map[string]string{"Host": "bar.com"}, + ExpectStatus: http.StatusNotFound, + ExpectBody: "{\"error_msg\":\"404 Route Not Found\"}\n", + Sleep: base.SleepTime, + }), + ) }) diff --git a/api/test/e2enew/route/route_with_plugin_limit_count_test.go b/api/test/e2enew/route/route_with_plugin_limit_count_test.go index 45e384bbad..a25d15c16f 100644 --- a/api/test/e2enew/route/route_with_plugin_limit_count_test.go +++ b/api/test/e2enew/route/route_with_plugin_limit_count_test.go @@ -50,7 +50,8 @@ var _ = ginkgo.Describe("route with limit plugin", func() { "count": 2, "time_window": 2, "rejected_code": 503, - "key": "remote_addr" + "key": "remote_addr", + "policy": "local" } }, "upstream": { @@ -137,7 +138,8 @@ var _ = ginkgo.Describe("route with limit plugin by consumer", func() { "count": 2, "time_window": 2, "rejected_code": 503, - "key": "consumer_name" + "key": "consumer_name", + "policy": "local" } }, "upstream": { @@ -305,7 +307,8 @@ var _ = ginkgo.Describe("route with limit count and disable", func() { "time_window": 2, "rejected_code": 503, "key": "remote_addr", - "disable": false + "disable": false, + "policy": "local" } }, "upstream": { @@ -362,7 +365,8 @@ var _ = ginkgo.Describe("route with limit count and disable", func() { "time_window": 2, "rejected_code": 503, "key": "remote_addr", - "disable": true + "disable": true, + "policy": "local" } }, "upstream": { diff --git a/api/test/e2enew/schema/schema_test.go b/api/test/e2enew/schema/schema_test.go index fb99b2735e..05127f4833 100644 --- a/api/test/e2enew/schema/schema_test.go +++ b/api/test/e2enew/schema/schema_test.go @@ -45,7 +45,7 @@ var _ = ginkgo.Describe("Schema Test", func() { Path: "/apisix/admin/schema/plugins/jwt-auth", Headers: map[string]string{"Authorization": base.GetToken()}, ExpectStatus: http.StatusOK, - ExpectBody: "{\"$comment\":\"this is a mark for our injected plugin schema\",\"properties\":{\"disable\":{\"type\":\"boolean\"}},\"type\":\"object\"}", + ExpectBody: `{"$comment":"this is a mark for our injected plugin schema","properties":{"cookie":{"default":"jwt","type":"string"},"disable":{"type":"boolean"},"header":{"default":"authorization","type":"string"},"query":{"default":"jwt","type":"string"}}`, Sleep: base.SleepTime, }), table.Entry("get schema of non-existent plugin", base.HttpTestCase{ @@ -63,7 +63,7 @@ var _ = ginkgo.Describe("Schema Test", func() { Path: "/apisix/admin/schemas/consumer", Headers: map[string]string{"Authorization": base.GetToken()}, ExpectStatus: http.StatusOK, - ExpectBody: `"properties":{"create_time":{"type":"integer"},"desc":{"maxLength":256,"type":"string"},"labels":{"description":"key/value pairs to specify attributes","maxProperties":16,"patternProperties":{".*":{"description":"value of label","maxLength":64,"minLength":1,"pattern":"^\\S+$","type":"string"}},"type":"object"},"plugins":{"type":"object"},"update_time":{"type":"integer"},"username":{"maxLength":100,"minLength":1,"pattern":"^[a-zA-Z0-9_]+$","type":"string"}}`, + ExpectBody: `"properties":{"create_time":{"type":"integer"},"desc":{"maxLength":256,"type":"string"},"labels":{"description":"key/value pairs to specify attributes","patternProperties":{".*":{"description":"value of label","maxLength":64,"minLength":1,"pattern":"^\\S+$","type":"string"}},"type":"object"},"plugins":{"type":"object"},"update_time":{"type":"integer"},"username":{"maxLength":100,"minLength":1,"pattern":"^[a-zA-Z0-9_]+$","type":"string"}}`, Sleep: base.SleepTime, }), table.Entry("get schema of non-existent resources", base.HttpTestCase{ diff --git a/api/test/e2enew/service/service_test.go b/api/test/e2enew/service/service_test.go index fde787b0c5..39643d9c6e 100644 --- a/api/test/e2enew/service/service_test.go +++ b/api/test/e2enew/service/service_test.go @@ -193,6 +193,7 @@ var _ = ginkgo.Describe("create service with plugin", func() { "time_window": 60, "rejected_code": 503, "key": "remote_addr", + "policy": "local", }, }, "upstream": map[string]interface{}{ @@ -225,7 +226,7 @@ var _ = ginkgo.Describe("create service with plugin", func() { Path: "/apisix/admin/services/s1", Headers: map[string]string{"Authorization": base.GetToken()}, ExpectCode: http.StatusOK, - ExpectBody: "\"upstream\":{\"nodes\":[{\"host\":\"" + base.UpstreamIp + "\",\"port\":1980,\"weight\":1}],\"type\":\"roundrobin\"},\"plugins\":{\"limit-count\":{\"count\":100,\"key\":\"remote_addr\",\"rejected_code\":503,\"time_window\":60}}", + ExpectBody: "\"upstream\":{\"nodes\":[{\"host\":\"" + base.UpstreamIp + "\",\"port\":1980,\"weight\":1}],\"type\":\"roundrobin\"},\"plugins\":{\"limit-count\":{\"count\":100,\"key\":\"remote_addr\",\"policy\":\"local\",\"rejected_code\":503,\"time_window\":60}}", Sleep: base.SleepTime, }) }) @@ -306,6 +307,7 @@ var _ = ginkgo.Describe("create service with all options via POST method", func( "time_window": 60, "rejected_code": 503, "key": "remote_addr", + "policy": "local", }, }, "upstream": map[string]interface{}{ @@ -341,7 +343,7 @@ var _ = ginkgo.Describe("create service with all options via POST method", func( Path: "/apisix/admin/services/s2", Headers: map[string]string{"Authorization": base.GetToken()}, ExpectCode: http.StatusOK, - ExpectBody: "\"name\":\"testservice22\",\"desc\":\"testservice_desc\",\"upstream\":{\"nodes\":[{\"host\":\"" + base.UpstreamIp + "\",\"port\":1980,\"weight\":1}],\"type\":\"roundrobin\"},\"plugins\":{\"limit-count\":{\"count\":100,\"key\":\"remote_addr\",\"rejected_code\":503,\"time_window\":60}},\"labels\":{\"build\":\"16\",\"env\":\"production\",\"version\":\"v2\"},\"enable_websocket\":true}", + ExpectBody: "\"name\":\"testservice22\",\"desc\":\"testservice_desc\",\"upstream\":{\"nodes\":[{\"host\":\"" + base.UpstreamIp + "\",\"port\":1980,\"weight\":1}],\"type\":\"roundrobin\"},\"plugins\":{\"limit-count\":{\"count\":100,\"key\":\"remote_addr\",\"policy\":\"local\",\"rejected_code\":503,\"time_window\":60}},\"labels\":{\"build\":\"16\",\"env\":\"production\",\"version\":\"v2\"},\"enable_websocket\":true}", Sleep: base.SleepTime, }) }) diff --git a/api/test/e2enew/system_config/system_config_suite_test.go b/api/test/e2enew/system_config/system_config_suite_test.go new file mode 100644 index 0000000000..122ca1f157 --- /dev/null +++ b/api/test/e2enew/system_config/system_config_suite_test.go @@ -0,0 +1,28 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package system_config + +import ( + "github.com/onsi/ginkgo" + "github.com/onsi/gomega" + "testing" +) + +func TestSystemConfig(t *testing.T) { + gomega.RegisterFailHandler(ginkgo.Fail) + ginkgo.RunSpecs(t, "system config suite") +} diff --git a/api/test/e2enew/system_config/system_config_test.go b/api/test/e2enew/system_config/system_config_test.go new file mode 100644 index 0000000000..7fc2166ee0 --- /dev/null +++ b/api/test/e2enew/system_config/system_config_test.go @@ -0,0 +1,126 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package system_config + +import ( + "net/http" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/ginkgo/extensions/table" + + "github.com/apisix/manager-api/test/e2enew/base" +) + +var _ = Describe("system config", func() { + DescribeTable("test system config data CURD", + func(tc base.HttpTestCase) { + base.RunTestCase(tc) + }, + + Entry("get system config should get not found error", base.HttpTestCase{ + Object: base.ManagerApiExpect(), + Method: http.MethodGet, + Path: "/apisix/admin/system_config/grafana", + Headers: map[string]string{"Authorization": base.GetToken()}, + ExpectStatus: http.StatusNotFound, + }), + + Entry("create system config should get schema validate failed error", base.HttpTestCase{ + Object: base.ManagerApiExpect(), + Method: http.MethodPost, + Path: "/apisix/admin/system_config", + Body: `{ + "config_name": "", + "payload": {"url":"http://127.0.0.1:3000"} + }`, + Headers: map[string]string{"Authorization": base.GetToken()}, + ExpectStatus: http.StatusBadRequest, + }), + + Entry("create system config should success", base.HttpTestCase{ + Object: base.ManagerApiExpect(), + Method: http.MethodPost, + Path: "/apisix/admin/system_config", + Body: `{ + "config_name": "grafana", + "payload": {"url":"http://127.0.0.1:3000"} + }`, + Headers: map[string]string{"Authorization": base.GetToken()}, + ExpectStatus: http.StatusOK, + ExpectBody: "\"config_name\":\"grafana\",\"payload\":{\"url\":\"http://127.0.0.1:3000\"}", + }), + + Entry("after create system config get config should succeed", base.HttpTestCase{ + Object: base.ManagerApiExpect(), + Method: http.MethodGet, + Path: "/apisix/admin/system_config/grafana", + Headers: map[string]string{"Authorization": base.GetToken()}, + ExpectStatus: http.StatusOK, + ExpectBody: "\"config_name\":\"grafana\",\"payload\":{\"url\":\"http://127.0.0.1:3000\"}", + }), + + Entry("update system config should get schema validate failed error", base.HttpTestCase{ + Object: base.ManagerApiExpect(), + Method: http.MethodPut, + Path: "/apisix/admin/system_config", + Body: `{ + "config_name": "", + "payload": {"url":"http://127.0.0.1:2000"} + }`, + Headers: map[string]string{"Authorization": base.GetToken()}, + ExpectStatus: http.StatusBadRequest, + }), + + Entry("update system config should success", base.HttpTestCase{ + Object: base.ManagerApiExpect(), + Method: http.MethodPut, + Path: "/apisix/admin/system_config", + Body: `{ + "config_name": "grafana", + "payload": {"url":"http://127.0.0.1:2000"} + }`, + Headers: map[string]string{"Authorization": base.GetToken()}, + ExpectStatus: http.StatusOK, + ExpectBody: "\"config_name\":\"grafana\",\"payload\":{\"url\":\"http://127.0.0.1:2000\"}", + }), + + Entry("after update system config get config should succeed", base.HttpTestCase{ + Object: base.ManagerApiExpect(), + Method: http.MethodGet, + Path: "/apisix/admin/system_config/grafana", + Headers: map[string]string{"Authorization": base.GetToken()}, + ExpectStatus: http.StatusOK, + ExpectBody: "\"config_name\":\"grafana\",\"payload\":{\"url\":\"http://127.0.0.1:2000\"}", + }), + + Entry("delete system config should success", base.HttpTestCase{ + Object: base.ManagerApiExpect(), + Method: http.MethodDelete, + Path: "/apisix/admin/system_config/grafana", + Headers: map[string]string{"Authorization": base.GetToken()}, + ExpectStatus: http.StatusOK, + }), + + Entry("get system config should get not found error", base.HttpTestCase{ + Object: base.ManagerApiExpect(), + Method: http.MethodGet, + Path: "/apisix/admin/system_config/grafana", + Headers: map[string]string{"Authorization": base.GetToken()}, + ExpectStatus: http.StatusNotFound, + }), + ) +}) diff --git a/api/test/e2enew/upstream/upstream_test.go b/api/test/e2enew/upstream/upstream_test.go index 4776bb7b4e..15a78da1bd 100644 --- a/api/test/e2enew/upstream/upstream_test.go +++ b/api/test/e2enew/upstream/upstream_test.go @@ -88,6 +88,63 @@ var _ = ginkgo.Describe("Upstream", func() { ExpectStatus: http.StatusOK, }) }) + ginkgo.It("create upstream3 success when pass host is 'node' and nodes without port", func() { + ginkgo.By("create upstream3", func() { + createUpstreamBody := make(map[string]interface{}) + createUpstreamBody["name"] = "upstream3" + createUpstreamBody["nodes"] = map[string]float64{base.UpstreamIp: 100} + createUpstreamBody["type"] = "roundrobin" + createUpstreamBody["pass_host"] = "node" + + _createUpstreamBody, err := json.Marshal(createUpstreamBody) + gomega.Expect(err).To(gomega.BeNil()) + base.RunTestCase(base.HttpTestCase{ + Object: base.ManagerApiExpect(), + Method: http.MethodPut, + Path: "/apisix/admin/upstreams/3", + Body: string(_createUpstreamBody), + Headers: map[string]string{"Authorization": base.GetToken()}, + ExpectStatus: http.StatusOK, + }) + }) + + ginkgo.By("create route using the upstream3", func() { + base.RunTestCase(base.HttpTestCase{ + Object: base.ManagerApiExpect(), + Method: http.MethodPut, + Path: "/apisix/admin/routes/1", + Body: `{ + "name": "route1", + "uri": "/hello", + "upstream_id": "3" + }`, + Headers: map[string]string{"Authorization": base.GetToken()}, + ExpectStatus: http.StatusOK, + Sleep: base.SleepTime, + }) + }) + + ginkgo.By("hit the route just created", func() { + base.RunTestCase(base.HttpTestCase{ + Object: base.APISIXExpect(), + Method: http.MethodGet, + Path: "/hello", + ExpectStatus: http.StatusOK, + ExpectBody: "hello", + Sleep: base.SleepTime, + }) + }) + + ginkgo.By("delete route", func() { + base.RunTestCase(base.HttpTestCase{ + Object: base.ManagerApiExpect(), + Method: http.MethodDelete, + Path: "/apisix/admin/routes/1", + Headers: map[string]string{"Authorization": base.GetToken()}, + ExpectStatus: http.StatusOK, + }) + }) + }) ginkgo.It("create upstream failed, name existed", func() { createUpstreamBody := make(map[string]interface{}) createUpstreamBody["name"] = "upstream2" @@ -252,6 +309,15 @@ var _ = ginkgo.Describe("Upstream", func() { ExpectStatus: http.StatusOK, }) }) + ginkgo.It("delete upstream3", func() { + base.RunTestCase(base.HttpTestCase{ + Object: base.ManagerApiExpect(), + Method: http.MethodDelete, + Path: "/apisix/admin/upstreams/3", + Headers: map[string]string{"Authorization": base.GetToken()}, + ExpectStatus: http.StatusOK, + }) + }) ginkgo.It("hit the route just deleted", func() { base.RunTestCase(base.HttpTestCase{ Object: base.APISIXExpect(), diff --git a/api/test/shell/cli_test.sh b/api/test/shell/cli_test.sh index e0b5371465..2946b9703d 100755 --- a/api/test/shell/cli_test.sh +++ b/api/test/shell/cli_test.sh @@ -200,6 +200,9 @@ stop_dashboard() { stop_dashboard 6 [ $(grep -c "/apisix/admin/user/login" "${ACCESS_LOG_FILE}") -ne '0' ] + + # check logging middleware + [ $(grep -c "filter/logging.go" "${ACCESS_LOG_FILE}") -ne '0' ] } #8 @@ -454,6 +457,32 @@ stop_dashboard() { recover_service_file } +#15 +@test "Check Security configuration" { + recover_conf + + start_dashboard 3 + + # check response header without custom header + run curl -i http://127.0.0.1:9000 + + [ $(echo "$output" | grep -c "X-Frame-Options: deny") -eq '1' ] + + stop_dashboard 6 + + sed -i 's@# security:@security:@' ${CONF_FILE} + sed -i 's@# x_frame_options: "deny"@ x_frame_options: "test"@' ${CONF_FILE} + + start_dashboard 3 + + # check response header with custom header + run curl -i http://127.0.0.1:9000 + +[ $(echo "$output" | grep -c "X-Frame-Options: test") -eq '1' ] + + stop_dashboard 6 +} + #post @test "Clean test environment" { # kill etcd diff --git a/api/test/testdata/invalid-dag-conf.json b/api/test/testdata/invalid-dag-conf.json index c11916ae82..260e06a3db 100644 --- a/api/test/testdata/invalid-dag-conf.json +++ b/api/test/testdata/invalid-dag-conf.json @@ -29,7 +29,8 @@ "count":2, "time_window":60, "rejected_code":503, - "key":"remote_addr" + "key":"remote_addr", + "policy": "local" } }, "yy-uu-ii-oo":{ diff --git a/docs/en/latest/IMPORT_OPENAPI_USER_GUIDE.md b/docs/en/latest/IMPORT_OPENAPI_USER_GUIDE.md index 40d1e52399..bd49c14ed4 100644 --- a/docs/en/latest/IMPORT_OPENAPI_USER_GUIDE.md +++ b/docs/en/latest/IMPORT_OPENAPI_USER_GUIDE.md @@ -219,6 +219,7 @@ paths: time_window: 60 rejected_code: 503 key: remote_addr + policy: local responses: '200': description: list response diff --git a/docs/en/latest/develop.md b/docs/en/latest/develop.md index 41c28abff7..51b695a709 100644 --- a/docs/en/latest/develop.md +++ b/docs/en/latest/develop.md @@ -25,7 +25,7 @@ The Dashboard contains both `manager-api` and `web` parts, so you need to start ## Prerequisites -Before development, refer to this [guide](./deploy.md) to install dependencies. +Before development, refer to this [guide](./install.md) to install dependencies. ## Clone the project @@ -67,7 +67,13 @@ $ make api-stop $ cd ./web ``` -2. Please change the `manager-api` address in the `config/defaultSettings.ts` file if needed. +2. Please change the `manager-api` address in the `config/defaultSettings.ts` file. If you follow this guidelines, the address may need to be set as below. + +``` +serveUrlMap:{ + dev: 'http://localhost:9000' +} +``` 3. Launch development mode @@ -77,4 +83,6 @@ $ yarn install $ yarn start ``` +> If there is an error about gyp during yarn install, please ignore it and go ahead! + 4. If writing an front end E2E test, please refer to the [Front End E2E Writing Guide](./front-end-e2e.md) diff --git a/docs/en/latest/install.md b/docs/en/latest/install.md index eb0d926696..f1063cb1ce 100644 --- a/docs/en/latest/install.md +++ b/docs/en/latest/install.md @@ -42,13 +42,13 @@ Please replace `` to your configure file path. ## RPM {#rpm} -**NOTE:** Only CentOS 7 is supported currently, for more information, please refer to [here](./deploy.md). +**NOTE:** Only CentOS 7 is supported currently. ### Install ```shell # 1. install RPM package -sudo yum install -y https://github.com/apache/apisix-dashboard/releases/download/v2.11.0/apisix-dashboard-2.11.0-0.el7.x86_64.rpm +sudo yum install -y https://github.com/apache/apisix-dashboard/releases/download/v2.11/apisix-dashboard-2.11-0.el7.x86_64.rpm ``` ### Launch diff --git a/web/cypress/fixtures/plugin-dataset.json b/web/cypress/fixtures/plugin-dataset.json index 6f02cdebfd..f56bb37eb4 100644 --- a/web/cypress/fixtures/plugin-dataset.json +++ b/web/cypress/fixtures/plugin-dataset.json @@ -593,7 +593,13 @@ "limit-count": [ { "shouldValid": true, - "data": { "count": 2, "time_window": 60, "rejected_code": 503, "key": "remote_addr" } + "data": { + "count": 2, + "time_window": 60, + "rejected_code": 503, + "key": "remote_addr", + "policy": "local" + } }, { "shouldValid": true, @@ -601,14 +607,16 @@ "count": 2, "time_window": 60, "rejected_code": 503, - "key": "host" + "key": "host", + "policy": "local" } }, { "shouldValid": false, "data": { "time_window": 60, - "rejected_code": 503 + "rejected_code": 503, + "policy": "local" } }, { @@ -617,7 +625,8 @@ "count": -100, "time_window": 60, "rejected_code": 503, - "key": "remote_addr" + "key": "remote_addr", + "policy": "local" } }, { @@ -626,7 +635,8 @@ "count": 2, "time_window": 60, "rejected_code": 503, - "key": "server_addr" + "key": "server_addr", + "policy": "local" } }, { @@ -634,7 +644,8 @@ "data": { "count": 2, "time_window": 60, - "key": "remote_addr" + "key": "remote_addr", + "policy": "local" } } ], diff --git a/web/cypress/integration/consumer/table-auto-jump-when-no-data.spec.js b/web/cypress/integration/consumer/table-auto-jump-when-no-data.spec.js new file mode 100644 index 0000000000..209a540cbc --- /dev/null +++ b/web/cypress/integration/consumer/table-auto-jump-when-no-data.spec.js @@ -0,0 +1,82 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +context('Table Auto Jump When No Data', () => { + const selector = { + username: '#username', + page_item: '.ant-pagination-item-2', + deleteAlert: '.ant-modal-body', + notificationCloseIcon: '.ant-notification-close-icon', + notification: '.ant-notification-notice-message', + table_row: '.ant-table-row', + pluginCard: '.ant-card', + drawer: '.ant-drawer-content', + monacoScroll: '.monaco-scrollable-element', + monacoViewZones: '.view-zones', + disabledSwitcher: '#disable', + popoper: '.ant-popover', + popoprerHiden: '.ant-popover-hidden', + }; + + const data = { + consumerName: 'test_consumer', + createConsumerSuccess: 'Create Consumer Successfully', + deleteConsumerSuccess: 'Delete Consumer Successfully', + }; + + before(() => { + cy.login().then(() => { + Array.from({ length: 11 }).forEach((value, key) => { + const payload = { + username: data.consumerName + key, + plugins: { + 'key-auth': { + key: 'test', + disable: false, + }, + }, + }; + cy.requestWithToken({ method: 'PUT', payload, url: '/apisix/admin/consumers' }); + }); + }); + }); + + it('should delete last data and jump to first page', () => { + cy.visit('/'); + cy.contains('Consumer').click(); + cy.get(selector.page_item).click(); + cy.wait(1000); + cy.contains('Delete').click(); + cy.get(selector.popoper) + .not(selector.popoprerHiden) + .contains('Confirm') + .should('be.visible') + .click(); + cy.get(selector.notification).should('contain', data.deleteConsumerSuccess); + cy.get(selector.notificationCloseIcon).click(); + cy.url().should('contains', '/consumer/list?page=1&pageSize=10'); + cy.get(selector.table_row).should((consumer) => { + expect(consumer).to.have.length(10); + }); + cy.get(`.ant-table-cell:contains(${data.consumerName})`).each((elem) => { + cy.requestWithToken({ + method: 'DELETE', + url: `/apisix/admin/consumers/${elem.text()}`, + }); + }); + }); +}); diff --git a/web/cypress/integration/pluginTemplate/create-edit-delete-plugin-template.spec.js b/web/cypress/integration/pluginTemplate/create-edit-delete-plugin-template.spec.js index 706135b799..8bdf052004 100644 --- a/web/cypress/integration/pluginTemplate/create-edit-delete-plugin-template.spec.js +++ b/web/cypress/integration/pluginTemplate/create-edit-delete-plugin-template.spec.js @@ -29,8 +29,9 @@ context('Create Configure and Delete PluginTemplate', () => { notification: '.ant-notification-notice-message', refresh: '.anticon-reload', descriptionSelector: '[title=Description]', + pluginTitle: '.ant-divider-inner-text', + pluginBtn: '.ant-btn-primary', }; - const data = { pluginTemplateName: 'test_plugin_template1', pluginTemplateName2: 'test_plugin_template2', @@ -74,6 +75,14 @@ context('Create Configure and Delete PluginTemplate', () => { cy.contains('Submit').click(); cy.contains('Next').click(); + cy.contains(selector.pluginCard, 'basic-auth').should('be.visible'); + cy.contains(selector.pluginTitle, 'Authentication').should('be.visible'); + cy.contains(selector.pluginTitle, 'Security').should('not.exist'); + cy.contains(selector.pluginTitle, 'Traffic Control').should('not.exist'); + cy.contains(selector.pluginTitle, 'Serverless').should('not.exist'); + cy.contains(selector.pluginTitle, 'Observability').should('not.exist'); + cy.contains(selector.pluginTitle, 'Other').should('not.exist'); + cy.contains(selector.pluginBtn, 'Enable').should('not.exist'); cy.contains('Submit').click(); cy.get(selector.notification).should('contain', data.createPluginTemplateSuccess); }); @@ -88,6 +97,14 @@ context('Create Configure and Delete PluginTemplate', () => { cy.get(selector.description).clear().type(data.pluginTemplateName2); cy.contains('Next').click(); cy.contains('Next').click(); + cy.contains(selector.pluginCard, 'basic-auth').should('be.visible'); + cy.contains(selector.pluginTitle, 'Authentication').should('be.visible'); + cy.contains(selector.pluginTitle, 'Security').should('not.exist'); + cy.contains(selector.pluginTitle, 'Traffic Control').should('not.exist'); + cy.contains(selector.pluginTitle, 'Serverless').should('not.exist'); + cy.contains(selector.pluginTitle, 'Observability').should('not.exist'); + cy.contains(selector.pluginTitle, 'Other').should('not.exist'); + cy.contains(selector.pluginBtn, 'Enable').should('not.exist'); cy.contains('Submit').click(); cy.get(selector.notification).should('contain', data.editPluginTemplateSuccess); diff --git a/web/cypress/integration/proto/create_and_edit_and_delete_proto.spce.js b/web/cypress/integration/proto/create_and_edit_and_delete_proto.spec.js similarity index 97% rename from web/cypress/integration/proto/create_and_edit_and_delete_proto.spce.js rename to web/cypress/integration/proto/create_and_edit_and_delete_proto.spec.js index 7443aa86d6..bad768622d 100644 --- a/web/cypress/integration/proto/create_and_edit_and_delete_proto.spce.js +++ b/web/cypress/integration/proto/create_and_edit_and_delete_proto.spec.js @@ -14,7 +14,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -/* eslint-disable no-undef */ context('Create and Delete Proto', () => { const selector = { @@ -41,7 +40,7 @@ optional string email = 3; }`, createProtoSuccess: 'Create proto Successfully', configureProtoSuccess: 'Configure proto Successfully', - deleteProtoSuccess: 'Delete Upstream Successfully', + deleteProtoSuccess: 'Delete proto Successfully', }; beforeEach(() => { diff --git a/web/cypress/integration/proto/table-auto-jump-when-no-data.spec.js b/web/cypress/integration/proto/table-auto-jump-when-no-data.spec.js new file mode 100644 index 0000000000..d81d862a64 --- /dev/null +++ b/web/cypress/integration/proto/table-auto-jump-when-no-data.spec.js @@ -0,0 +1,68 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +context('Batch Create Proto And Delete Proto', () => { + const selector = { + id: '#id', + content: '.view-lines', + page_item: '.ant-pagination-item-2', + draw: '.ant-drawer-content', + deleteAlert: '.ant-modal-body', + notificationCloseIcon: '.ant-notification-close-icon', + notification: '.ant-notification-notice-message', + table_row: '.ant-table-row', + }; + + const data = { + createProtoSuccess: 'Create proto Successfully', + deleteProtoSuccess: 'Delete proto Successfully', + }; + + before(() => { + cy.login().then(() => { + Array.from({ length: 11 }).forEach(async (value, key) => { + const payload = { + content: 'test', + desc: '', + id: `protoId${key}`, + }; + cy.requestWithToken({ method: 'POST', payload, url: '/apisix/admin/proto' }); + }); + }); + }); + + it('should delete last data and jump to first page', () => { + cy.visit('/'); + cy.contains('Protocol Buffers').click(); + cy.get(selector.page_item).click(); + cy.wait(1000); + cy.contains('Delete').click(); + cy.contains('button', 'Confirm').click(); + cy.get(selector.notification).should('contain', data.deleteProtoSuccess); + cy.get(selector.notificationCloseIcon).click(); + cy.url().should('contains', '/proto/list?page=1&pageSize=10'); + cy.get(selector.table_row).should((proto) => { + expect(proto).to.have.length(10); + }); + cy.get('.ant-table-cell:contains(protoId)').each((elem) => { + cy.requestWithToken({ + method: 'DELETE', + url: `/apisix/admin/proto/${elem.text()}`, + }); + }); + }); +}); diff --git a/web/cypress/integration/route/import_export_route.spec.js b/web/cypress/integration/route/import_export_route.spec.js index ec93db3a1e..c27708e14e 100644 --- a/web/cypress/integration/route/import_export_route.spec.js +++ b/web/cypress/integration/route/import_export_route.spec.js @@ -131,16 +131,19 @@ context('import and export routes', () => { cy.log(`found file ${jsonFile}`); cy.log('**confirm downloaded json file**'); cy.readFile(jsonFile).then((fileContent) => { - expect(JSON.stringify(fileContent)).to.equal(JSON.stringify(this.exportFile.jsonFile)); + const json = fileContent; + delete json['paths']['/{params}']['post']['x-apisix-id']; + expect(JSON.stringify(json)).to.equal(JSON.stringify(this.exportFile.jsonFile)); }); }); cy.task('findFile', data.yamlMask).then((yamlFile) => { cy.log(`found file ${yamlFile}`); cy.log('**confirm downloaded yaml file**'); cy.readFile(yamlFile).then((fileContent) => { - expect(JSON.stringify(yaml.load(fileContent), null, null)).to.equal( - JSON.stringify(this.exportFile.yamlFile), - ); + const json = yaml.load(fileContent); + delete json['paths']['/{params}']['post']['x-apisix-id']; + delete json['paths']['/{params}-APISIX-REPEAT-URI-2']['post']['x-apisix-id']; + expect(JSON.stringify(json, null, null)).to.equal(JSON.stringify(this.exportFile.yamlFile)); }); }); }); diff --git a/web/cypress/integration/route/table-auto-jump-when-no-data.spec.js b/web/cypress/integration/route/table-auto-jump-when-no-data.spec.js new file mode 100644 index 0000000000..5f0a12569e --- /dev/null +++ b/web/cypress/integration/route/table-auto-jump-when-no-data.spec.js @@ -0,0 +1,94 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +context('Table Auto Jump When No Data', () => { + const selector = { + name: '#name', + nodes_0_host: '#submitNodes_0_host', + page_item: '.ant-pagination-item-2', + deleteAlert: '.ant-modal-body', + notificationCloseIcon: '.ant-notification-close-icon', + notification: '.ant-notification-notice-message', + table_row: '.ant-table-row', + }; + + const data = { + submitSuccess: 'Submit Successfully', + deleteRouteSuccess: 'Delete Route Successfully', + }; + + before(() => { + cy.login().then(() => { + Array.from({ length: 11 }).forEach((value, key) => { + const payload = { + methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS', 'CONNECT', 'TRACE'], + priority: 0, + name: `routeName${key}`, + desc: '', + status: 1, + labels: {}, + uri: '/*', + upstream: { + type: 'roundrobin', + pass_host: 'pass', + scheme: 'http', + timeout: { + connect: 6, + send: 6, + read: 6, + }, + keepalive_pool: { + size: 320, + idle_timeout: 60, + requests: 1000, + }, + nodes: { + '127.0.0.1': 1, + }, + }, + }; + cy.requestWithToken({ method: 'POST', payload, url: '/apisix/admin/routes' }); + }); + }); + }); + + it('should delete last data and jump to first page', () => { + cy.visit('/'); + cy.contains('Route').click(); + cy.get(selector.page_item).click(); + cy.wait(1000); + cy.contains('routeName').siblings().contains('More').click(); + cy.contains('Delete').click(); + cy.get(selector.deleteAlert) + .should('be.visible') + .within(() => { + cy.contains('OK').click(); + }); + cy.get(selector.notification).should('contain', data.deleteRouteSuccess); + cy.get(selector.notificationCloseIcon).click(); + cy.url().should('contains', '/routes/list?page=1&pageSize=10'); + cy.get(selector.table_row).should((route) => { + expect(route).to.have.length(10); + }); + cy.get('.ant-table-cell:contains(routeName)').each((elem) => { + cy.requestWithToken({ + method: 'DELETE', + url: `/apisix/admin/routes/${elem.next().text()}`, + }); + }); + }); +}); diff --git a/web/cypress/integration/service/table-auto-jump-when-no-data.spec.js b/web/cypress/integration/service/table-auto-jump-when-no-data.spec.js new file mode 100644 index 0000000000..98f9eb80c0 --- /dev/null +++ b/web/cypress/integration/service/table-auto-jump-when-no-data.spec.js @@ -0,0 +1,85 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/* eslint-disable no-undef */ + +context('Table Auto Jump When No Data', () => { + const selector = { + name: '#name', + nodes_0_host: '#submitNodes_0_host', + page_item: '.ant-pagination-item-2', + deleteAlert: '.ant-modal-body', + notificationCloseIcon: '.ant-notification-close-icon', + notification: '.ant-notification-notice-message', + table_row: '.ant-table-row', + }; + + const data = { + createServiceSuccess: 'Create Service Successfully', + deleteServiceSuccess: 'Delete Service Successfully', + }; + + beforeEach(() => { + cy.login().then(() => { + Array.from({ length: 11 }).forEach((value, key) => { + const payload = { + name: `serviceName${key}`, + plugins: {}, + upstream: { + type: 'roundrobin', + pass_host: 'pass', + scheme: 'http', + timeout: { + connect: 6, + send: 6, + read: 6, + }, + keepalive_pool: { + size: 320, + idle_timeout: 60, + requests: 1000, + }, + nodes: { + '127.0.0.1': 1, + }, + }, + }; + cy.requestWithToken({ method: 'POST', payload, url: '/apisix/admin/services' }); + }); + }); + }); + + it('should delete last data and jump to first page', () => { + cy.visit('/'); + cy.contains('Service').click(); + cy.get(selector.page_item).click(); + cy.wait(1000); + cy.contains('serviceName').siblings().contains('Delete').click(); + cy.contains('button', 'Confirm').click(); + cy.get(selector.notification).should('contain', data.deleteServiceSuccess); + cy.get(selector.notificationCloseIcon).click(); + cy.url().should('contains', '/service/list?page=1&pageSize=10'); + cy.get(selector.table_row).should((service) => { + expect(service).to.have.length(10); + }); + cy.get('.ant-table-cell:contains(serviceName)').each((elem) => { + cy.requestWithToken({ + method: 'DELETE', + url: `/apisix/admin/services/${elem.prev().text()}`, + }); + }); + }); +}); diff --git a/web/cypress/integration/upstream/table-auto-jump-when-no-data.spec.js b/web/cypress/integration/upstream/table-auto-jump-when-no-data.spec.js new file mode 100644 index 0000000000..bc80371364 --- /dev/null +++ b/web/cypress/integration/upstream/table-auto-jump-when-no-data.spec.js @@ -0,0 +1,83 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/* eslint-disable no-undef */ + +context('Table Auto Jump When No Data', () => { + const selector = { + id: '#id', + name: '#name', + nodes_0_host: '#submitNodes_0_host', + page_item: '.ant-pagination-item-2', + deleteAlert: '.ant-modal-body', + notificationCloseIcon: '.ant-notification-close-icon', + notification: '.ant-notification-notice-message', + table_row: ' .ant-table-row', + }; + + const data = { + createUpstreamSuccess: 'Create Upstream Successfully', + deleteUpstreamSuccess: 'Delete Upstream Successfully', + }; + + beforeEach(() => { + cy.login().then(() => { + Array.from({ length: 11 }).forEach((value, key) => { + const payload = { + name: `upstreamName${key}`, + type: 'roundrobin', + pass_host: 'pass', + scheme: 'http', + timeout: { + connect: 6, + send: 6, + read: 6, + }, + keepalive_pool: { + size: 320, + idle_timeout: 60, + requests: 1000, + }, + nodes: { + '127.0.0.1': 1, + }, + }; + cy.requestWithToken({ method: 'POST', payload, url: `/apisix/admin/upstreams` }); + }); + }); + }); + + it('should delete the upstream', () => { + cy.visit('/'); + cy.contains('Upstream').click(); + cy.get(selector.page_item).click(); + cy.wait(1000); + cy.contains('upstreamName').siblings().contains('Delete').click(); + cy.contains('button', 'Confirm').click(); + cy.get(selector.notification).should('contain', data.deleteUpstreamSuccess); + cy.get('.ant-notification-close-x').click(); + cy.url().should('contains', '/upstream/list?page=1&pageSize=10'); + cy.get(selector.table_row).should((upstream) => { + expect(upstream).to.have.length(10); + }); + cy.get('.ant-table-cell:contains(upstreamName)').each((elem) => { + cy.requestWithToken({ + method: 'DELETE', + url: `/apisix/admin/upstreams/${elem.prev().text()}`, + }); + }); + }); +}); diff --git a/web/cypress/support/commands.js b/web/cypress/support/commands.js index 990288a88b..0a7ae8c7d3 100644 --- a/web/cypress/support/commands.js +++ b/web/cypress/support/commands.js @@ -143,3 +143,17 @@ Cypress.Commands.add('configurePlugin', ({ name, cases }) => { } }); }); + +Cypress.Commands.add('requestWithToken', ({ method, url, payload }) => { + const { SERVE_ENV = 'dev' } = Cypress.env(); + // Make sure the request is synchronous + cy.request({ + method, + url: defaultSettings.serveUrlMap[SERVE_ENV] + url, + body: payload, + headers: { Authorization: localStorage.getItem('token') }, + }).then((res) => { + expect(res.body.code).to.equal(0); + return res; + }); +}); diff --git a/web/package.json b/web/package.json index ae3479df68..5750a03a28 100644 --- a/web/package.json +++ b/web/package.json @@ -69,7 +69,7 @@ "js-beautify": "^1.13.0", "js-yaml": "^4.0.0", "lodash": "^4.17.11", - "moment": "^2.25.3", + "moment": "^2.29.2", "nzh": "1.0.4", "omit.js": "^2.0.2", "path-to-regexp": "2.4.0", diff --git a/web/src/components/Footer/index.tsx b/web/src/components/Footer/index.tsx index 5dc35cc3d1..bd222733a7 100644 --- a/web/src/components/Footer/index.tsx +++ b/web/src/components/Footer/index.tsx @@ -23,7 +23,11 @@ import { DefaultFooter } from '@ant-design/pro-layout'; export default () => ( >>>>>> upstream links={[ // { // key: '官网', diff --git a/web/src/components/Plugin/PluginDetail.tsx b/web/src/components/Plugin/PluginDetail.tsx index fde1626838..4170a156e3 100644 --- a/web/src/components/Plugin/PluginDetail.tsx +++ b/web/src/components/Plugin/PluginDetail.tsx @@ -129,10 +129,16 @@ const PluginDetail: React.FC = ({ if (name === 'cors') { const newMethods = formData.allow_methods.join(','); - const compactAllowRegex = compact(formData.allow_origins_by_regex); - // Note: default allow_origins_by_regex setted for UI is [''], but this is not allowed, omit it. - if (compactAllowRegex.length === 0) { - return omit({ ...formData, allow_methods: newMethods }, ['allow_origins_by_regex']); + const isFilterAllowRegex = compact(formData.allow_origins_by_regex).length === 0; + const isFilterAllowMetadata = compact(formData.allow_origins_by_metadata).length === 0; + // Note: default allow_origins_by_regex and allow_origins_by_metadata setted for UI is [''], but this is not allowed, omit it. + if (isFilterAllowRegex || isFilterAllowMetadata) { + const filterAllowRegex = (isFilterAllowRegex && 'allow_origins_by_regex') || ''; + const filterAllowMetadata = (isFilterAllowMetadata && 'allow_origins_by_metadata') || ''; + return omit({ ...formData, allow_methods: newMethods }, [ + filterAllowRegex, + filterAllowMetadata, + ]); } return { ...formData, allow_methods: newMethods }; @@ -309,7 +315,10 @@ const PluginDetail: React.FC = ({ }; const isNoConfigurationRequired = - pluginType === 'auth' && schemaType !== 'consumer' && monacoMode !== monacoModeList.UIForm && targetPluginName !== 'key-auth'; + pluginType === 'auth' && + schemaType !== 'consumer' && + monacoMode !== monacoModeList.UIForm && + targetPluginName !== 'key-auth'; return ( = ({ form.setFieldsValue({ plugin_config_id }); }); }, []); + const openPluginList = pluginList.filter( + (item) => initialData[item.name] && !initialData[item.name].disable, + ); + const openPluginType = openPluginList.map((item) => item.type); + const newOpenPluginType = openPluginType.filter((elem, index, self) => { + return index === self.indexOf(elem); + }); const PluginList = () => ( <> @@ -179,7 +186,7 @@ const PluginPage: React.FC = ({ /> )} - {typeList.map((typeItem) => { + {(readonly ? newOpenPluginType : typeList).map((typeItem) => { return ( = ({ id={`plugin-category-${typeItem}`} > {orderBy( - pluginList.filter((item) => item.type === typeItem.toLowerCase() && !item.hidden), + pluginList.filter( + readonly + ? (item) => item.type === typeItem && !item.hidden && initialData[item.name] + : (item) => item.type === typeItem && !item.hidden, + ), 'name', 'asc', ).map((item) => ( diff --git a/web/src/components/Plugin/UI/cors.tsx b/web/src/components/Plugin/UI/cors.tsx index b639bb12b2..5c2bbd2ef8 100644 --- a/web/src/components/Plugin/UI/cors.tsx +++ b/web/src/components/Plugin/UI/cors.tsx @@ -28,7 +28,7 @@ type Props = { const FORM_ITEM_LAYOUT = { labelCol: { - span: 7, + span: 8, }, wrapperCol: { span: 8, @@ -37,16 +37,20 @@ const FORM_ITEM_LAYOUT = { export const FORM_ITEM_WITHOUT_LABEL = { wrapperCol: { - sm: { span: 8, offset: 7 }, + sm: { span: 8, offset: 8 }, }, }; const Cors: React.FC = ({ form, schema }) => { const { formatMessage } = useIntl(); - const properties = schema?.properties - const regexPro = properties.allow_origins_by_regex - const { minLength, maxLength } = regexPro.items - const regexInit = Array(regexPro.minItems).join('.').split('.') + const properties = schema?.properties; + const regexPro = properties.allow_origins_by_regex; + const metadataPro = properties.allow_origins_by_metadata; + + const { minLength, maxLength } = regexPro.items; + const { minLength: metadataMinLength, maxLength: metadataMaxLength } = metadataPro.items; + const regexInit = Array(regexPro.minItems).join('.').split('.'); + const metadataInit = Array(metadataPro.minItems).join('.').split('.'); const HTTPMethods: React.FC = () => ( = ({ form, schema }) => { > - + + + {fields.length > metadataMinLength && ( + { + remove(field.name); + }} + /> + )} + + ))} + { + + {fields.length < metadataMaxLength && ( + + )} + + } + + ); + }} + + + {(fields, { add, remove }) => { return (
@@ -159,7 +206,7 @@ const Cors: React.FC = ({ form, schema }) => { - {fields.length > minLength && + {fields.length > minLength && ( = ({ form, schema }) => { remove(field.name); }} /> - } + )} ))} { - {fields.length < maxLength && } + {fields.length < maxLength && ( + + )} }
diff --git a/web/src/components/Plugin/UI/limit-count.tsx b/web/src/components/Plugin/UI/limit-count.tsx index 15eb59553d..29c359e84b 100644 --- a/web/src/components/Plugin/UI/limit-count.tsx +++ b/web/src/components/Plugin/UI/limit-count.tsx @@ -239,7 +239,8 @@ const LimitCount: React.FC = ({ form, schema }) => { const properties = schema?.properties; const [policy, setPoicy] = useState(properties.policy.default); const { formatMessage } = useIntl(); - const dependSchema = schema?.dependencies.policy.oneOf; + const redisSchema = schema?.then; + const redisClusterSchema = schema?.else.then; return (
@@ -367,8 +368,8 @@ const LimitCount: React.FC = ({ form, schema }) => { setPoicy(form.getFieldValue('policy')); }} - {Boolean(policy === 'redis') && } - {Boolean(policy === 'redis-cluster') && } + {Boolean(policy === 'redis') && } + {Boolean(policy === 'redis-cluster') && } ); }; diff --git a/web/src/components/Plugin/UI/proxy-mirror.tsx b/web/src/components/Plugin/UI/proxy-mirror.tsx index 5b19cabbfe..4fca5a07be 100644 --- a/web/src/components/Plugin/UI/proxy-mirror.tsx +++ b/web/src/components/Plugin/UI/proxy-mirror.tsx @@ -26,7 +26,7 @@ type Props = { const FORM_ITEM_LAYOUT = { labelCol: { - span: 4, + span: 6, }, wrapperCol: { span: 10, @@ -42,14 +42,29 @@ const ProxyMirror: React.FC = ({ form, schema }) => { + + + diff --git a/web/src/components/Plugin/data.tsx b/web/src/components/Plugin/data.tsx index 0f40f042f8..7b1d499707 100644 --- a/web/src/components/Plugin/data.tsx +++ b/web/src/components/Plugin/data.tsx @@ -269,4 +269,43 @@ export const PLUGIN_LIST = { 'aws-lambda': { type: PluginType.serverless, }, + 'clickhouse-logger': { + type: PluginType.observability, + }, + 'client-control': { + type: PluginType.traffic, + }, + csrf: { + type: PluginType.security, + }, + 'ext-plugin-post-req': { + type: PluginType.other, + }, + 'ext-plugin-pre-req': { + type: PluginType.other, + }, + 'file-logger': { + type: PluginType.observability, + }, + gzip: { + type: PluginType.other, + }, + loggly: { + type: PluginType.observability, + }, + 'public-api': { + type: PluginType.security, + }, + 'real-ip': { + type: PluginType.other, + }, + 'authz-casdoor': { + type: PluginType.authentication, + }, + mocking: { + type: PluginType.other, + }, + opentelemetry: { + type: PluginType.observability, + }, }; diff --git a/web/src/components/Plugin/locales/en-US.ts b/web/src/components/Plugin/locales/en-US.ts index f87b8da31e..b7d6a705eb 100644 --- a/web/src/components/Plugin/locales/en-US.ts +++ b/web/src/components/Plugin/locales/en-US.ts @@ -46,6 +46,8 @@ export default { 'Maximum number of seconds the results can be cached. Within this time range, the browser will reuse the last check result. -1 means no cache. Please note that the maximum value is depended on browser, please refer to MDN for details.', 'component.pluginForm.cors.allow_credential.tooltip': "If you set this option to true, you can not use '*' for other options.", + 'component.pluginForm.cors.allow_origins_by_metadata.tooltip': + 'Match which origin is allowed to enable CORS by referencing allow_origins set in plugin metadata.', 'component.pluginForm.cors.allow_origins_by_regex.tooltip': 'Use regex expressions to match which origin is allowed to enable CORS, for example, [".*.test.com"] can use to match all subdomain of test.com.', @@ -78,6 +80,9 @@ export default { 'component.pluginForm.proxy-mirror.host.extra': 'e.g. http://127.0.0.1:9797', 'component.pluginForm.proxy-mirror.host.ruletip': 'address needs to contain schema: http or https, not URI part', + 'component.pluginForm.proxy-mirror.path.tooltip': + "Specify the mirror request's path part. Without it the current path will be used.", + 'component.pluginForm.proxy-mirror.path.ruletip': 'Please enter the correct path, e.g. /path', 'component.pluginForm.proxy-mirror.sample_ratio.tooltip': 'the sample ratio that requests will be mirrored.', diff --git a/web/src/components/Plugin/locales/zh-CN.ts b/web/src/components/Plugin/locales/zh-CN.ts index 41ed113d0e..6368d166fb 100644 --- a/web/src/components/Plugin/locales/zh-CN.ts +++ b/web/src/components/Plugin/locales/zh-CN.ts @@ -45,6 +45,8 @@ export default { '浏览器缓存 CORS 结果的最大时间,单位为秒,在这个时间范围内浏览器会复用上一次的检查结果,-1 表示不缓存。', 'component.pluginForm.cors.allow_credential.tooltip': '是否允许跨域访问的请求方携带凭据(如 Cookie 等)。根据 CORS 规范,如果设置该选项为 true,那么将不能在其他选项中使用 * 。', + 'component.pluginForm.cors.allow_origins_by_metadata.tooltip': + '通过引用插件元数据的 allow_origins 配置允许跨域访问的 Origin。', 'component.pluginForm.cors.allow_origins_by_regex.tooltip': '使用正则表达式数组来匹配允许跨域访问的 Origin, 如[".*.test.com"] 可以匹配任何test.com的子域名 * 。', @@ -73,6 +75,9 @@ export default { 'component.pluginForm.proxy-mirror.host.extra': '例如:http://127.0.0.1:9797', 'component.pluginForm.proxy-mirror.host.ruletip': '地址中需要包含 schema :http或https,不能包含 URI 部分', + 'component.pluginForm.proxy-mirror.path.tooltip': + '指定镜像请求的路径。如不指定,当前路径将被使用。', + 'component.pluginForm.proxy-mirror.path.ruletip': '请输入正确的路径,例如: /path', 'component.pluginForm.proxy-mirror.sample_ratio.tooltip': '镜像请求采样率', // limit-conn diff --git a/web/src/components/PluginFlow/locales/en-US.ts b/web/src/components/PluginFlow/locales/en-US.ts index 4e5c8f215c..72a9f73426 100644 --- a/web/src/components/PluginFlow/locales/en-US.ts +++ b/web/src/components/PluginFlow/locales/en-US.ts @@ -20,7 +20,7 @@ export default { 'component.plugin-flow.text.condition2': 'Condition', 'component.plugin-flow.text.condition.placeholder': 'Please enter the rule', 'component.plugin-flow.text.without-data': 'Found node without configuration', - 'component.plugin-flow.text.plugin-without-data.description': 'Please condigure plugin: ', + 'component.plugin-flow.text.plugin-without-data.description': 'Please configure plugin: ', 'component.plugin-flow.text.no-start-node': 'Please connect the start node', 'component.plugin-flow.text.no-root-node': 'Root node not found', 'component.plugin-flow.text.start-node': 'Start', diff --git a/web/src/components/Upstream/components/ServiceDiscovery.tsx b/web/src/components/Upstream/components/ServiceDiscovery.tsx index deda6484ad..cbd6eb4a18 100644 --- a/web/src/components/Upstream/components/ServiceDiscovery.tsx +++ b/web/src/components/Upstream/components/ServiceDiscovery.tsx @@ -32,6 +32,7 @@ const discoveryType = { args: ['group_name', 'namespace_id'], }, eureka: {}, + kubernetes: {}, }; const ServiceDiscovery: React.FC = ({ readonly, form }) => { diff --git a/web/src/components/Upstream/locales/en-US.ts b/web/src/components/Upstream/locales/en-US.ts index 8dcf67b058..b1ba0fe663 100644 --- a/web/src/components/Upstream/locales/en-US.ts +++ b/web/src/components/Upstream/locales/en-US.ts @@ -31,6 +31,7 @@ export default { 'component.upstream.fields.discovery_type.type.consul_kv': 'Consul KV', 'component.upstream.fields.discovery_type.type.nacos': 'Nacos', 'component.upstream.fields.discovery_type.type.eureka': 'Eureka', + 'component.upstream.fields.discovery_type.type.kubernetes': 'Kubernetes', 'component.upstream.fields.discovery_args.group_name': 'Group Name', 'component.upstream.fields.discovery_args.group_name.tooltip': 'Group Name', @@ -135,7 +136,7 @@ export default { 'component.upstream.fields.checks.active.unhealthy.interval.tooltip': 'Interval between active health checks for unhealthy targets (in seconds). A value of zero indicates that active probes for healthy targets should not be performed.', 'component.upstream.fields.checks.active.unhealthy.required': - 'Please enter the unhelthy interval', + 'Please enter the unhealthy interval', 'component.upstream.fields.checks.passive.healthy.successes': 'Successes', 'component.upstream.fields.checks.passive.healthy.successes.tooltip': diff --git a/web/src/components/Upstream/locales/zh-CN.ts b/web/src/components/Upstream/locales/zh-CN.ts index 4e1dc1001f..5de7c6e238 100644 --- a/web/src/components/Upstream/locales/zh-CN.ts +++ b/web/src/components/Upstream/locales/zh-CN.ts @@ -31,6 +31,7 @@ export default { 'component.upstream.fields.discovery_type.type.consul_kv': 'Consul KV', 'component.upstream.fields.discovery_type.type.nacos': 'Nacos', 'component.upstream.fields.discovery_type.type.eureka': 'Eureka', + 'component.upstream.fields.discovery_type.type.kubernetes': 'Kubernetes', 'component.upstream.fields.discovery_args.group_name': '分组名', 'component.upstream.fields.discovery_args.group_name.tooltip': '分组名', diff --git a/web/src/hooks/usePagination.ts b/web/src/hooks/usePagination.ts index 41938c705e..c6b1a02d22 100644 --- a/web/src/hooks/usePagination.ts +++ b/web/src/hooks/usePagination.ts @@ -17,6 +17,9 @@ import { useEffect, useState } from 'react'; import { useLocation, history } from 'umi'; import querystring from 'query-string'; +import type { PageInfo } from '@ant-design/pro-table/lib/typing'; +import type { ActionType } from '@ant-design/pro-table'; +import type { MutableRefObject } from 'react'; export default function usePagination() { const location = useLocation(); @@ -30,5 +33,14 @@ export default function usePagination() { history.replace(`${location.pathname}?page=${page}&pageSize=${pageSize}`); }; - return { paginationConfig, savePageList }; + const checkPageList = (ref: MutableRefObject) => { + const { current, pageSize, total } = ref.current?.pageInfo as PageInfo; + if (current > pageSize / total && current > 1) { + savePageList(paginationConfig.current - 1, paginationConfig.pageSize); + } else { + ref.current?.reload(); + } + }; + + return { paginationConfig, savePageList, checkPageList }; } diff --git a/web/src/pages/Consumer/List.tsx b/web/src/pages/Consumer/List.tsx index a0a62cc5ec..bcd27e8a46 100644 --- a/web/src/pages/Consumer/List.tsx +++ b/web/src/pages/Consumer/List.tsx @@ -37,7 +37,7 @@ const Page: React.FC = () => { const [rawData, setRawData] = useState>({}); const [id, setId] = useState(''); const [editorMode, setEditorMode] = useState<'create' | 'update'>('create'); - const { paginationConfig, savePageList } = usePagination(); + const { paginationConfig, savePageList, checkPageList } = usePagination(); const columns: ProColumns[] = [ { @@ -59,7 +59,7 @@ const Page: React.FC = () => { title: formatMessage({ id: 'menu.plugin' }), dataIndex: 'plugins', hideInSearch: true, - render: (_, record) => Object.keys(record.plugins).join(','), + render: (_, record) => Object.keys(record.plugins || []).join(','), }, { title: formatMessage({ id: 'component.global.operation' }), @@ -95,8 +95,7 @@ const Page: React.FC = () => { notification.success({ message: `${formatMessage({ id: 'component.global.delete.consumer.success' })}`, }); - /* eslint-disable no-unused-expressions */ - ref.current?.reload(); + checkPageList(ref); }); }} > diff --git a/web/src/pages/Plugin/List.tsx b/web/src/pages/Plugin/List.tsx index c9b4a93316..7d9be5db44 100644 --- a/web/src/pages/Plugin/List.tsx +++ b/web/src/pages/Plugin/List.tsx @@ -35,7 +35,7 @@ const Page: React.FC = () => { const [initialData, setInitialData] = useState({}); const [pluginList, setPluginList] = useState([]); const [name, setName] = useState(''); - const { paginationConfig, savePageList } = usePagination(); + const { paginationConfig, savePageList, checkPageList } = usePagination(); useEffect(() => { fetchPluginList().then(setPluginList); @@ -85,7 +85,7 @@ const Page: React.FC = () => { id: 'menu.plugin', })} ${formatMessage({ id: 'component.status.success' })}`, }); - ref.current?.reload(); + checkPageList(ref); setInitialData(plugins); setName(''); }); diff --git a/web/src/pages/Proto/List.tsx b/web/src/pages/Proto/List.tsx index b4c80da396..07f344e886 100755 --- a/web/src/pages/Proto/List.tsx +++ b/web/src/pages/Proto/List.tsx @@ -16,7 +16,8 @@ */ import React, { useRef, useState } from 'react'; import { PageHeaderWrapper } from '@ant-design/pro-layout'; -import ProTable, { ProColumns, ActionType } from '@ant-design/pro-table'; +import type { ProColumns, ActionType } from '@ant-design/pro-table'; +import ProTable from '@ant-design/pro-table'; import ProtoDrawer from './components/ProtoDrawer'; import { Button, notification, Popconfirm, Space } from 'antd'; import { useIntl } from 'umi'; @@ -31,7 +32,7 @@ const Page: React.FC = () => { const ref = useRef(); const { formatMessage } = useIntl(); const [drawerVisible, setDrawerVisible] = useState(false); - const { paginationConfig, savePageList } = usePagination(); + const { paginationConfig, savePageList, checkPageList } = usePagination(); const emptyProtoData = { id: null, content: '', @@ -92,21 +93,20 @@ const Page: React.FC = () => { {formatMessage({ id: 'component.global.edit' })} { remove(record.id).then(() => { notification.success({ - message: formatMessage({ id: 'page.upstream.list.delete.successfully' }), + message: formatMessage({ id: 'page.proto.list.delete.successfully' }), }); - /* eslint-disable no-unused-expressions */ - ref.current?.reload(); + checkPageList(ref); }); }} > diff --git a/web/src/pages/Proto/locales/en-US.ts b/web/src/pages/Proto/locales/en-US.ts index 69074cc51d..13f1f1f7d8 100644 --- a/web/src/pages/Proto/locales/en-US.ts +++ b/web/src/pages/Proto/locales/en-US.ts @@ -18,7 +18,12 @@ export default { 'page.proto.list': 'Proto List', 'page.proto.list.description': "Protocol buffers are Google's language-neutral, platform-neutral, extensible mechanism for serializing structured data.You define how you want your data to be structured once, then you can use special generated source code to easily write and read your structured data to and from a variety of data streams and using a variety of languages.The protocol buffers list contains the created proto files. When the grpc transcode plug-in is enabled, the ID can be configured to read the contents of the corresponding proto files.", - + 'page.proto.list.edit': 'Configure', + 'page.proto.list.confirm.delete': 'Are you sure to delete ?', + 'page.proto.list.confirm': 'Confirm', + 'page.proto.list.cancel': 'Cancel', + 'page.proto.list.delete.successfully': 'Delete proto Successfully', + 'page.proto.list.delete': 'Delete', 'page.proto.id.tooltip': ".proto file's id", 'page.proto.desc': 'description', @@ -31,4 +36,5 @@ export default { 'page.proto.drawer.edit': 'Configure proto', 'page.proto.drawer.create.successfully': 'Create proto Successfully', 'page.proto.drawer.edit.successfully': 'Configure proto Successfully', + 'page.proto.drawer.delete.successfully': 'Delete proto Successfully', }; diff --git a/web/src/pages/Proto/locales/zh-CN.ts b/web/src/pages/Proto/locales/zh-CN.ts index 2fe11235cb..ac68d8c08f 100644 --- a/web/src/pages/Proto/locales/zh-CN.ts +++ b/web/src/pages/Proto/locales/zh-CN.ts @@ -18,6 +18,12 @@ export default { 'page.proto.list': 'Proto 列表', 'page.proto.list.description': 'Protocol Buffers 是 Google 用于序列化结构化数据的框架,它具有语言中立、平台中立、可扩展机制的特性,您只需定义一次数据的结构化方式,然后就可以使用各种语言通过特殊生成的源代码轻松地将结构化数据写入和读取各种数据流。Protocol Buffers 列表包含了已创建的 proto 文件,在启用 grpc-transcode 插件时可配置 ID 读取对应的 proto 文件内容。', + 'page.proto.list.edit': '配置', + 'page.proto.list.confirm.delete': '确定删除该条记录吗?', + 'page.proto.list.confirm': '确定', + 'page.proto.list.cancel': '取消', + 'page.proto.list.delete.successfully': '删除记录成功', + 'page.proto.list.delete': '删除', 'page.proto.id.tooltip': '.proto 文件的 id', @@ -30,5 +36,6 @@ export default { 'page.proto.drawer.create': '创建 proto', 'page.proto.drawer.edit': '配置 proto', 'page.proto.drawer.create.successfully': '创建 proto 成功', - 'page.proto.drawer.edit.successfully': '配置 proto', + 'page.proto.drawer.edit.successfully': '配置 proto 成功', + 'page.proto.drawer.delete.successfully': '删除 proto 成功', }; diff --git a/web/src/pages/Route/List.tsx b/web/src/pages/Route/List.tsx index 1b1b21cc0d..8634c2c334 100755 --- a/web/src/pages/Route/List.tsx +++ b/web/src/pages/Route/List.tsx @@ -87,7 +87,7 @@ const Page: React.FC = () => { const [rawData, setRawData] = useState>({}); const [id, setId] = useState(''); const [editorMode, setEditorMode] = useState<'create' | 'update'>('create'); - const { paginationConfig, savePageList } = usePagination(); + const { paginationConfig, savePageList, checkPageList } = usePagination(); const [debugDrawVisible, setDebugDrawVisible] = useState(false); useEffect(() => { @@ -107,7 +107,7 @@ const Page: React.FC = () => { message: msgTip, }); - ref.current?.reload(); + checkPageList(ref); }; const handlePublishOffline = (rid: string, status: RouteModule.RouteStatus) => { @@ -228,47 +228,47 @@ const Page: React.FC = () => { onClick: () => void; icon?: ReactNode; }[] = [ - { - name: formatMessage({ id: 'component.global.view' }), - onClick: () => { - setId(record.id); - setRawData(omit(record, DELETE_FIELDS)); - setVisible(true); - setEditorMode('update'); + { + name: formatMessage({ id: 'component.global.view' }), + onClick: () => { + setId(record.id); + setRawData(omit(record, DELETE_FIELDS)); + setVisible(true); + setEditorMode('update'); + }, }, - }, - { - name: formatMessage({ id: 'component.global.duplicate' }), - onClick: () => { - history.push(`/routes/${record.id}/duplicate`); + { + name: formatMessage({ id: 'component.global.duplicate' }), + onClick: () => { + history.push(`/routes/${record.id}/duplicate`); + }, }, - }, - { - name: formatMessage({ id: 'component.global.delete' }), - onClick: () => { - Modal.confirm({ - type: 'warning', - title: formatMessage({ id: 'component.global.popconfirm.title.delete' }), - content: ( - <> - {formatMessage({ id: 'component.global.name' })} - {record.name} -
- ID - {record.id} - - ), - onOk: () => { - return remove(record.id!).then(() => { - handleTableActionSuccessResponse( - `${formatMessage({ id: 'component.global.delete' })} ${formatMessage({ - id: 'menu.routes', - })} ${formatMessage({ id: 'component.status.success' })}`, - ); - }); - }, - }); + { + name: formatMessage({ id: 'component.global.delete' }), + onClick: () => { + Modal.confirm({ + type: 'warning', + title: formatMessage({ id: 'component.global.popconfirm.title.delete' }), + content: ( + <> + {formatMessage({ id: 'component.global.name' })} - {record.name} +
+ ID - {record.id} + + ), + onOk: () => { + return remove(record.id!).then(() => { + handleTableActionSuccessResponse( + `${formatMessage({ id: 'component.global.delete' })} ${formatMessage({ + id: 'menu.routes', + })} ${formatMessage({ id: 'component.status.success' })}`, + ); + }); + }, + }); + }, }, - }, - ]; + ]; return ( { const tableRef = useRef(); const { formatMessage } = useIntl(); - const { paginationConfig, savePageList } = usePagination(); + const { paginationConfig, savePageList, checkPageList } = usePagination(); const columns: ProColumns[] = [ { @@ -75,8 +75,7 @@ const Page: React.FC = () => { notification.success({ message: formatMessage({ id: 'component.ssl.removeSSLSuccess' }), }); - /* eslint-disable no-unused-expressions */ - requestAnimationFrame(() => tableRef.current?.reload()); + requestAnimationFrame(() => checkPageList(tableRef)); }) } cancelText={formatMessage({ id: 'component.global.cancel' })} diff --git a/web/src/pages/Service/List.tsx b/web/src/pages/Service/List.tsx index 0946a4b1f0..948f112709 100644 --- a/web/src/pages/Service/List.tsx +++ b/web/src/pages/Service/List.tsx @@ -35,7 +35,7 @@ const Page: React.FC = () => { const [rawData, setRawData] = useState>({}); const [id, setId] = useState(''); const [editorMode, setEditorMode] = useState<'create' | 'update'>('create'); - const { paginationConfig, savePageList } = usePagination(); + const { paginationConfig, savePageList, checkPageList } = usePagination(); const columns: ProColumns[] = [ { @@ -82,8 +82,7 @@ const Page: React.FC = () => { id: 'menu.service', })} ${formatMessage({ id: 'component.status.success' })}`, }); - /* eslint-disable no-unused-expressions */ - ref.current?.reload(); + checkPageList(ref); }); }} okText={formatMessage({ id: 'component.global.confirm' })} diff --git a/web/src/pages/Upstream/List.tsx b/web/src/pages/Upstream/List.tsx index 5d6931338b..c0348743e0 100644 --- a/web/src/pages/Upstream/List.tsx +++ b/web/src/pages/Upstream/List.tsx @@ -37,7 +37,7 @@ const Page: React.FC = () => { const [rawData, setRawData] = useState>({}); const [id, setId] = useState(''); const [editorMode, setEditorMode] = useState<'create' | 'update'>('create'); - const { paginationConfig, savePageList } = usePagination(); + const { paginationConfig, savePageList, checkPageList } = usePagination(); const { formatMessage } = useIntl(); const columns: ProColumns[] = [ @@ -95,8 +95,7 @@ const Page: React.FC = () => { notification.success({ message: formatMessage({ id: 'page.upstream.list.delete.successfully' }), }); - /* eslint-disable no-unused-expressions */ - ref.current?.reload(); + checkPageList(ref); }); }} > diff --git a/web/yarn.lock b/web/yarn.lock index 78ea470e0d..e8fec3d88f 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -4513,11 +4513,11 @@ axe-core@^4.0.2: integrity sha512-vwPpH4Aj4122EW38mxO/fxhGKtwWTMLDIJfZ1He0Edbtjcfna/R3YB67yVhezUMzqc3Jr3+Ii50KRntlENL4xQ== axios@^0.21.1: - version "0.21.1" - resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.1.tgz#22563481962f4d6bde9a76d516ef0e5d3c09b2b8" - integrity sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA== + version "0.21.4" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.4.tgz#c67b90dc0568e5c1cf2b0b858c43ba28e2eda575" + integrity sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg== dependencies: - follow-redirects "^1.10.0" + follow-redirects "^1.14.0" axobject-query@^2.2.0: version "2.2.0" @@ -8148,10 +8148,10 @@ flatten@^1.0.2: resolved "https://registry.yarnpkg.com/flatten/-/flatten-1.0.3.tgz#c1283ac9f27b368abc1e36d1ff7b04501a30356b" integrity sha512-dVsPA/UwQ8+2uoFe5GHtiBMu48dWLTdsuEd7CKGlZlD78r1TTWBvDuFaFGKCo/ZfEr95Uk56vZoX86OsHkUeIg== -follow-redirects@^1.0.0, follow-redirects@^1.10.0: - version "1.13.3" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.13.3.tgz#e5598ad50174c1bc4e872301e82ac2cd97f90267" - integrity sha512-DUgl6+HDzB0iEptNQEXLx/KhTmDb8tZUHSeLqpnjpknR70H0nC2t9N73BK6fN4hOvJ84pKlIQVQ4k5FFlBedKA== +follow-redirects@^1.0.0, follow-redirects@^1.14.0: + version "1.14.9" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.9.tgz#dd4ea157de7bfaf9ea9b3fbd85aa16951f78d8d7" + integrity sha512-MQDfihBQYMcyy5dhRDJUHcw7lb2Pv/TuE6xP1vyraLukNDHKbDxDNaOE3NbCAdKQApno+GPRyo1YAp89yCjK4w== for-each@^0.3.3: version "0.3.3" @@ -11583,10 +11583,10 @@ module-deps@^6.0.0, module-deps@^6.2.3: through2 "^2.0.0" xtend "^4.0.0" -moment@^2.24.0, moment@^2.25.3, moment@^2.27.0, moment@^2.29.1: - version "2.29.1" - resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3" - integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ== +moment@^2.24.0, moment@^2.25.3, moment@^2.27.0, moment@^2.29.1, moment@^2.29.2: + version "2.29.2" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.2.tgz#00910c60b20843bcba52d37d58c628b47b1f20e4" + integrity sha512-UgzG4rvxYpN15jgCmVJwac49h9ly9NurikMWGPdVxm8GZD6XjkKPxDTjQQ43gtGgnV3X0cAyWDdP2Wexoquifg== monaco-editor@^0.32.1: version "0.32.1"