Compare commits
	
		
			7 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 7ea55fb466 | |||
| 76a0bdd67c | |||
| 8de525a8f8 | |||
| ef36082f2e | |||
| 21c897be47 | |||
| 0b21dd6660 | |||
| 18eb0d9e5c | 
							
								
								
									
										8
									
								
								.github/ISSUE_TEMPLATE/bug_report.md
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										8
									
								
								.github/ISSUE_TEMPLATE/bug_report.md
									
									
									
									
										vendored
									
									
								
							| @@ -1,6 +1,6 @@ | ||||
| --- | ||||
| name: Bug report | ||||
| about: For reporting bugs in micro | ||||
| about: For reporting bugs in go-micro | ||||
| title: "[BUG]" | ||||
| labels: '' | ||||
| assignees: '' | ||||
| @@ -16,3 +16,9 @@ assignees: '' | ||||
| **How to reproduce the bug:** | ||||
|  | ||||
| If possible, please include a minimal code snippet here. | ||||
|  | ||||
| **Environment:** | ||||
| Go Version: please paste `go version` output here | ||||
| ``` | ||||
| please paste `go env` output here | ||||
| ``` | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| --- | ||||
| name: Feature request / Enhancement | ||||
| about: If you have a need not served by micro | ||||
| about: If you have a need not served by go-micro | ||||
| title: "[FEATURE]" | ||||
| labels: '' | ||||
| assignees: '' | ||||
| @@ -14,4 +14,4 @@ A clear and concise description of what the problem is. Ex. I'm always frustrate | ||||
| A clear and concise description of what you want to happen. | ||||
|  | ||||
| **Additional context** | ||||
| Add any other context or screenshots about the feature request here. | ||||
| Add any other context or screenshots about the feature request here. | ||||
|   | ||||
							
								
								
									
										10
									
								
								.github/ISSUE_TEMPLATE/question.md
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										10
									
								
								.github/ISSUE_TEMPLATE/question.md
									
									
									
									
										vendored
									
									
								
							| @@ -1,8 +1,14 @@ | ||||
| --- | ||||
| name: Question | ||||
| about: Ask a question about micro | ||||
| about: Ask a question about go-micro | ||||
| title: '' | ||||
| labels: '' | ||||
| assignees: '' | ||||
|  | ||||
| --- | ||||
| --- | ||||
|  | ||||
| Before asking, please check if your question has already been answered:  | ||||
|  | ||||
| 1. Check the documentation - https://micro.mu/docs/ | ||||
| 2. Check the examples and plugins - https://github.com/micro/examples & https://github.com/micro/go-plugins | ||||
| 3. Search existing issues | ||||
|   | ||||
							
								
								
									
										19
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | ||||
| # To get started with Dependabot version updates, you'll need to specify which | ||||
| # package ecosystems to update and where the package manifests are located. | ||||
| # Please see the documentation for all configuration options: | ||||
| # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates | ||||
|  | ||||
| version: 2 | ||||
| updates: | ||||
|  | ||||
|   # Maintain dependencies for GitHub Actions | ||||
|   - package-ecosystem: "github-actions" | ||||
|     directory: "/" | ||||
|     schedule: | ||||
|       interval: "daily" | ||||
|  | ||||
|   # Maintain dependencies for Golang | ||||
|   - package-ecosystem: "gomod" | ||||
|     directory: "/" | ||||
|     schedule: | ||||
|       interval: "daily" | ||||
							
								
								
									
										20
									
								
								.github/workflows/autoapprove.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								.github/workflows/autoapprove.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,20 @@ | ||||
| name: "autoapprove" | ||||
|  | ||||
| on: | ||||
|   pull_request_target: | ||||
|     types: [assigned, opened, synchronize, reopened] | ||||
|  | ||||
| permissions: | ||||
|   pull-requests: write | ||||
|   contents: write | ||||
|  | ||||
| jobs: | ||||
|   autoapprove: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - name: approve | ||||
|         uses: hmarr/auto-approve-action@v3 | ||||
|         if: github.actor == 'vtolstov' || github.actor == 'dependabot[bot]' | ||||
|         id: approve | ||||
|         with: | ||||
|           github-token: ${{ secrets.GITHUB_TOKEN }} | ||||
							
								
								
									
										21
									
								
								.github/workflows/automerge.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								.github/workflows/automerge.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,21 @@ | ||||
| name: "automerge" | ||||
|  | ||||
| on: | ||||
|   pull_request_target: | ||||
|     types: [assigned, opened, synchronize, reopened] | ||||
|  | ||||
| permissions: | ||||
|   pull-requests: write | ||||
|   contents: write | ||||
|  | ||||
| jobs: | ||||
|   automerge: | ||||
|     runs-on: ubuntu-latest | ||||
|     if: github.actor == 'vtolstov' | ||||
|     steps: | ||||
|       - name: merge | ||||
|         id: merge | ||||
|         run: gh pr merge --auto --merge "$PR_URL" | ||||
|         env: | ||||
|           PR_URL: ${{github.event.pull_request.html_url}} | ||||
|           GITHUB_TOKEN: ${{secrets.TOKEN}} | ||||
							
								
								
									
										47
									
								
								.github/workflows/build.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								.github/workflows/build.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,47 @@ | ||||
| name: build | ||||
| on: | ||||
|  push: | ||||
|     branches: | ||||
|     - master | ||||
|     - v3 | ||||
| jobs: | ||||
|   test: | ||||
|     name: test | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|     - name: setup | ||||
|       uses: actions/setup-go@v3 | ||||
|       with: | ||||
|         go-version: 1.17 | ||||
|     - name: checkout | ||||
|       uses: actions/checkout@v3 | ||||
|     - name: cache | ||||
|       uses: actions/cache@v3 | ||||
|       with: | ||||
|         path: ~/go/pkg/mod | ||||
|         key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} | ||||
|         restore-keys: ${{ runner.os }}-go- | ||||
|     - name: deps | ||||
|       run: go get -v -t -d ./... | ||||
|     - name: test | ||||
|       env: | ||||
|         INTEGRATION_TESTS: yes | ||||
|       run: go test -mod readonly -v ./... | ||||
|   lint: | ||||
|     name: lint | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - name: checkout | ||||
|         uses: actions/checkout@v3 | ||||
|       - name: lint | ||||
|         uses: golangci/golangci-lint-action@v3.4.0 | ||||
|         continue-on-error: true | ||||
|         with: | ||||
|           # Required: the version of golangci-lint is required and must be specified without patch version: we always use the latest patch version. | ||||
|           version: v1.30 | ||||
|           # Optional: working directory, useful for monorepos | ||||
|           # working-directory: somedir | ||||
|           # Optional: golangci-lint command line arguments. | ||||
|           # args: --issues-exit-code=0 | ||||
|           # Optional: show only new issues if it's a pull request. The default value is `false`. | ||||
|           # only-new-issues: true | ||||
							
								
								
									
										78
									
								
								.github/workflows/codeql-analysis.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										78
									
								
								.github/workflows/codeql-analysis.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,78 @@ | ||||
| # For most projects, this workflow file will not need changing; you simply need | ||||
| # to commit it to your repository. | ||||
| # | ||||
| # You may wish to alter this file to override the set of languages analyzed, | ||||
| # or to provide custom queries or build logic. | ||||
| # | ||||
| # ******** NOTE ******** | ||||
| # We have attempted to detect the languages in your repository. Please check | ||||
| # the `language` matrix defined below to confirm you have the correct set of | ||||
| # supported CodeQL languages. | ||||
| # | ||||
| name: "codeql" | ||||
|  | ||||
| on: | ||||
|   workflow_run: | ||||
|     workflows: ["prbuild"] | ||||
|     types: | ||||
|       - completed | ||||
|   push: | ||||
|     branches: [ master, v3 ] | ||||
|   pull_request: | ||||
|     # The branches below must be a subset of the branches above | ||||
|     branches: [ master, v3 ] | ||||
|   schedule: | ||||
|     - cron: '34 1 * * 0' | ||||
|  | ||||
| jobs: | ||||
|   analyze: | ||||
|     name: analyze | ||||
|     runs-on: ubuntu-latest | ||||
|     permissions: | ||||
|       actions: read | ||||
|       contents: read | ||||
|       security-events: write | ||||
|  | ||||
|     strategy: | ||||
|       fail-fast: false | ||||
|       matrix: | ||||
|         language: [ 'go' ] | ||||
|         # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] | ||||
|         # Learn more: | ||||
|         # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed | ||||
|  | ||||
|     steps: | ||||
|     - name: checkout | ||||
|       uses: actions/checkout@v3 | ||||
|     - name: setup | ||||
|       uses: actions/setup-go@v3 | ||||
|       with: | ||||
|         go-version: 1.17 | ||||
|     # Initializes the CodeQL tools for scanning. | ||||
|     - name: init | ||||
|       uses: github/codeql-action/init@v2 | ||||
|       with: | ||||
|         languages: ${{ matrix.language }} | ||||
|         # If you wish to specify custom queries, you can do so here or in a config file. | ||||
|         # By default, queries listed here will override any specified in a config file. | ||||
|         # Prefix the list here with "+" to use these queries and those in the config file. | ||||
|         # queries: ./path/to/local/query, your-org/your-repo/queries@main | ||||
|  | ||||
|     # Autobuild attempts to build any compiled languages  (C/C++, C#, or Java). | ||||
|     # If this step fails, then you should remove it and run the build manually (see below) | ||||
|     - name: autobuild | ||||
|       uses: github/codeql-action/autobuild@v2 | ||||
|  | ||||
|     # ℹ️ Command-line programs to run using the OS shell. | ||||
|     # 📚 https://git.io/JvXDl | ||||
|  | ||||
|     # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines | ||||
|     #    and modify them (or add more) to build your code if your project | ||||
|     #    uses a compiled language | ||||
|  | ||||
|     #- run: | | ||||
|     #   make bootstrap | ||||
|     #   make release | ||||
|  | ||||
|     - name: analyze | ||||
|       uses: github/codeql-action/analyze@v2 | ||||
							
								
								
									
										27
									
								
								.github/workflows/dependabot-automerge.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								.github/workflows/dependabot-automerge.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | ||||
| name: "dependabot-automerge" | ||||
|  | ||||
| on: | ||||
|   pull_request_target: | ||||
|     types: [assigned, opened, synchronize, reopened] | ||||
|  | ||||
| permissions: | ||||
|   pull-requests: write | ||||
|   contents: write | ||||
|  | ||||
| jobs: | ||||
|   automerge: | ||||
|     runs-on: ubuntu-latest | ||||
|     if: github.actor == 'dependabot[bot]' | ||||
|     steps: | ||||
|       - name: metadata | ||||
|         id: metadata | ||||
|         uses: dependabot/fetch-metadata@v1.3.6 | ||||
|         with: | ||||
|           github-token: "${{ secrets.TOKEN }}" | ||||
|       - name: merge | ||||
|         id: merge | ||||
|         if: ${{contains(steps.metadata.outputs.dependency-names, 'go.unistack.org')}} | ||||
|         run: gh pr merge --auto --merge "$PR_URL" | ||||
|         env: | ||||
|           PR_URL: ${{github.event.pull_request.html_url}} | ||||
|           GITHUB_TOKEN: ${{secrets.TOKEN}} | ||||
							
								
								
									
										53
									
								
								.github/workflows/job_coverage.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										53
									
								
								.github/workflows/job_coverage.yml
									
									
									
									
										vendored
									
									
								
							| @@ -1,53 +0,0 @@ | ||||
| name: coverage | ||||
|  | ||||
| on: | ||||
|   push: | ||||
|     branches: [ main, v3, v4 ] | ||||
|     paths-ignore: | ||||
|       - '.github/**' | ||||
|       - '.gitea/**' | ||||
|   pull_request: | ||||
|     branches: [ main, v3, v4 ] | ||||
|  | ||||
| jobs: | ||||
|  | ||||
|   build: | ||||
|     if: github.server_url != 'https://github.com' | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|     - name: checkout code | ||||
|       uses: actions/checkout@v4 | ||||
|       with: | ||||
|         filter: 'blob:none' | ||||
|  | ||||
|     - name: setup go | ||||
|       uses: actions/setup-go@v5 | ||||
|       with: | ||||
|         cache-dependency-path: "**/*.sum" | ||||
|         go-version: 'stable' | ||||
|  | ||||
|     - name: test coverage | ||||
|       run: | | ||||
|         go test -v -cover ./... -covermode=count -coverprofile coverage.out -coverpkg ./... | ||||
|         go tool cover -func coverage.out -o coverage.out | ||||
|  | ||||
|     - name: coverage badge | ||||
|       uses: tj-actions/coverage-badge-go@v2 | ||||
|       with: | ||||
|         green: 80 | ||||
|         filename: coverage.out | ||||
|  | ||||
|     - uses: stefanzweifel/git-auto-commit-action@v4 | ||||
|       name: autocommit | ||||
|       with: | ||||
|         commit_message: Apply Code Coverage Badge | ||||
|         skip_fetch: false | ||||
|         skip_checkout: false | ||||
|         file_pattern: ./README.md | ||||
|  | ||||
|     - name: push | ||||
|       if: steps.auto-commit-action.outputs.changes_detected == 'true' | ||||
|       uses: ad-m/github-push-action@master | ||||
|       with: | ||||
|         github_token: ${{ github.token }} | ||||
|         branch: ${{ github.ref }} | ||||
							
								
								
									
										29
									
								
								.github/workflows/job_lint.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										29
									
								
								.github/workflows/job_lint.yml
									
									
									
									
										vendored
									
									
								
							| @@ -1,29 +0,0 @@ | ||||
| name: lint | ||||
|  | ||||
| on: | ||||
|   pull_request: | ||||
|     types: [opened, reopened, synchronize] | ||||
|     branches: [ master, v3, v4 ] | ||||
|     paths-ignore: | ||||
|       - '.github/**' | ||||
|       - '.gitea/**' | ||||
|  | ||||
| jobs: | ||||
|   lint: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|     - name: checkout code | ||||
|       uses: actions/checkout@v4 | ||||
|       with: | ||||
|         filter: 'blob:none' | ||||
|     - name: setup go | ||||
|       uses: actions/setup-go@v5 | ||||
|       with: | ||||
|         cache-dependency-path: "**/*.sum" | ||||
|         go-version: 'stable' | ||||
|     - name: setup deps | ||||
|       run: go get -v ./... | ||||
|     - name: run lint | ||||
|       uses: golangci/golangci-lint-action@v6 | ||||
|       with: | ||||
|         version: 'latest' | ||||
							
								
								
									
										31
									
								
								.github/workflows/job_test.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										31
									
								
								.github/workflows/job_test.yml
									
									
									
									
										vendored
									
									
								
							| @@ -1,31 +0,0 @@ | ||||
| name: test | ||||
|  | ||||
| on: | ||||
|   pull_request: | ||||
|     types: [opened, reopened, synchronize] | ||||
|     branches: [ master, v3, v4 ] | ||||
|   push: | ||||
|     branches: [ master, v3, v4 ] | ||||
|     paths-ignore: | ||||
|       - '.github/**' | ||||
|       - '.gitea/**' | ||||
|  | ||||
| jobs: | ||||
|   test: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|     - name: checkout code | ||||
|       uses: actions/checkout@v4 | ||||
|       with: | ||||
|         filter: 'blob:none' | ||||
|     - name: setup go | ||||
|       uses: actions/setup-go@v5 | ||||
|       with: | ||||
|         cache-dependency-path: "**/*.sum" | ||||
|         go-version: 'stable' | ||||
|     - name: setup deps | ||||
|       run: go get -v ./... | ||||
|     - name: run test | ||||
|       env: | ||||
|         INTEGRATION_TESTS: yes | ||||
|       run: go test -mod readonly -v ./... | ||||
							
								
								
									
										50
									
								
								.github/workflows/job_tests.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										50
									
								
								.github/workflows/job_tests.yml
									
									
									
									
										vendored
									
									
								
							| @@ -1,50 +0,0 @@ | ||||
| name: test | ||||
|  | ||||
| on: | ||||
|   pull_request: | ||||
|     types: [opened, reopened, synchronize] | ||||
|     branches: [ master, v3, v4 ] | ||||
|   push: | ||||
|     branches: [ master, v3, v4 ] | ||||
|     paths-ignore: | ||||
|       - '.github/**' | ||||
|       - '.gitea/**' | ||||
|  | ||||
| jobs: | ||||
|   test: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|     - name: checkout code | ||||
|       uses: actions/checkout@v4 | ||||
|       with: | ||||
|         filter: 'blob:none' | ||||
|     - name: checkout tests | ||||
|       uses: actions/checkout@v4 | ||||
|       with: | ||||
|         ref: master | ||||
|         filter: 'blob:none' | ||||
|         repository: unistack-org/micro-tests | ||||
|         path: micro-tests | ||||
|     - name: setup go | ||||
|       uses: actions/setup-go@v5 | ||||
|       with: | ||||
|         cache-dependency-path: "**/*.sum" | ||||
|         go-version: 'stable' | ||||
|     - name: setup go work | ||||
|       env: | ||||
|         GOWORK: ${{ github.workspace }}/go.work | ||||
|       run: | | ||||
|         go work init | ||||
|         go work use . | ||||
|         go work use micro-tests | ||||
|     - name: setup deps | ||||
|       env: | ||||
|         GOWORK: ${{ github.workspace }}/go.work | ||||
|       run: go get -v ./... | ||||
|     - name: run tests | ||||
|       env: | ||||
|         INTEGRATION_TESTS: yes | ||||
|         GOWORK: ${{ github.workspace }}/go.work | ||||
|       run: | | ||||
|         cd micro-tests | ||||
|         go test -mod readonly -v ./... || true | ||||
							
								
								
									
										47
									
								
								.github/workflows/pr.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								.github/workflows/pr.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,47 @@ | ||||
| name: prbuild | ||||
| on: | ||||
|   pull_request: | ||||
|     branches: | ||||
|     - master | ||||
|     - v3 | ||||
| jobs: | ||||
|   test: | ||||
|     name: test | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|     - name: setup | ||||
|       uses: actions/setup-go@v3 | ||||
|       with: | ||||
|         go-version: 1.17 | ||||
|     - name: checkout | ||||
|       uses: actions/checkout@v3 | ||||
|     - name: cache | ||||
|       uses: actions/cache@v3 | ||||
|       with: | ||||
|         path: ~/go/pkg/mod | ||||
|         key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} | ||||
|         restore-keys: ${{ runner.os }}-go- | ||||
|     - name: deps | ||||
|       run: go get -v -t -d ./... | ||||
|     - name: test | ||||
|       env: | ||||
|         INTEGRATION_TESTS: yes | ||||
|       run: go test -mod readonly -v ./... | ||||
|   lint: | ||||
|     name: lint | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - name: checkout | ||||
|         uses: actions/checkout@v3 | ||||
|       - name: lint | ||||
|         uses: golangci/golangci-lint-action@v3.4.0 | ||||
|         continue-on-error: true | ||||
|         with: | ||||
|           # Required: the version of golangci-lint is required and must be specified without patch version: we always use the latest patch version. | ||||
|           version: v1.30 | ||||
|           # Optional: working directory, useful for monorepos | ||||
|           # working-directory: somedir | ||||
|           # Optional: golangci-lint command line arguments. | ||||
|           # args: --issues-exit-code=0 | ||||
|           # Optional: show only new issues if it's a pull request. The default value is `false`. | ||||
|           # only-new-issues: true | ||||
							
								
								
									
										39
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										39
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -1,39 +0,0 @@ | ||||
| # Develop tools | ||||
| /.vscode/ | ||||
| /.idea/ | ||||
| .idea | ||||
| .vscode | ||||
|  | ||||
| # Binaries for programs and plugins | ||||
| *.exe | ||||
| *.exe~ | ||||
| *.dll | ||||
| *.so | ||||
| *.dylib | ||||
|  | ||||
| # Folders | ||||
| _obj | ||||
| _test | ||||
| _build | ||||
| .DS_Store | ||||
|  | ||||
| # Architecture specific extensions/prefixes | ||||
| *.[568vq] | ||||
| [568vq].out | ||||
|  | ||||
| *.cgo1.go | ||||
| *.cgo2.c | ||||
| _cgo_defun.c | ||||
| _cgo_gotypes.go | ||||
| _cgo_export.* | ||||
|  | ||||
| # Test binary, build with `go test -c` | ||||
| *.test | ||||
|  | ||||
| # Output of the go coverage tool, specifically when used with LiteIDE | ||||
| *.out | ||||
|  | ||||
| # vim temp files | ||||
| *~ | ||||
| *.swp | ||||
| *.swo | ||||
| @@ -1,5 +1,44 @@ | ||||
| run: | ||||
|   concurrency: 8 | ||||
|   timeout: 5m | ||||
|   concurrency: 4 | ||||
|   deadline: 5m | ||||
|   issues-exit-code: 1 | ||||
|   tests: true | ||||
|  | ||||
| linters-settings: | ||||
|   govet: | ||||
|     check-shadowing: true | ||||
|     enable: | ||||
|       - fieldalignment | ||||
|  | ||||
| linters: | ||||
|   enable: | ||||
|     - govet | ||||
|     - deadcode | ||||
|     - errcheck | ||||
|     - govet | ||||
|     - ineffassign | ||||
|     - staticcheck | ||||
|     - structcheck | ||||
|     - typecheck | ||||
|     - unused | ||||
|     - varcheck | ||||
|     - bodyclose | ||||
|     - gci | ||||
|     - goconst | ||||
|     - gocritic | ||||
|     - gosimple | ||||
|     - gofmt | ||||
|     - gofumpt | ||||
|     - goimports | ||||
|     - golint | ||||
|     - gosec | ||||
|     - makezero | ||||
|     - misspell | ||||
|     - nakedret | ||||
|     - nestif | ||||
|     - nilerr | ||||
|     - noctx | ||||
|     - prealloc | ||||
|     - unconvert | ||||
|     - unparam | ||||
|   disable-all: false | ||||
|   | ||||
							
								
								
									
										160
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										160
									
								
								README.md
									
									
									
									
									
								
							| @@ -1,26 +1,18 @@ | ||||
| # HTTP Client | ||||
|  | ||||
|  | ||||
| This plugin is an HTTP client for [Micro](https://pkg.go.dev/go.unistack.org/micro/v3). | ||||
| It implements the [micro.Client](https://pkg.go.dev/go.unistack.org/micro/v3/client#Client) interface. | ||||
| This plugin is a http client for micro. | ||||
|  | ||||
| ## Overview | ||||
|  | ||||
| The HTTP client wraps `net/http` to provide a robust client with service discovery, load balancing and  | ||||
| implements HTTP rules defined in the [google/api/http.proto](https://github.com/googleapis/googleapis/blob/master/google/api/http.proto) specification. | ||||
|  | ||||
| ## Limitations | ||||
|  | ||||
| * Streaming is not yet implemented. | ||||
| * Only protobuf-generated messages are supported. | ||||
| The http client wraps `net/http` to provide a robust micro client with service discovery, load balancing and streaming.  | ||||
| It complies with the [micro.Client](https://godoc.org/go.unistack.org/micro-client-http/v4#Client) interface. | ||||
|  | ||||
| ## Usage | ||||
|  | ||||
| ### Use directly | ||||
|  | ||||
| ```go | ||||
| import ( | ||||
|     "go.unistack.org/micro/v3" | ||||
|     http "go.unistack.org/micro-client-http/v3" | ||||
| ) | ||||
| import "go.unistack.org/micro-client-http/v4" | ||||
|  | ||||
| service := micro.NewService( | ||||
| 	micro.Name("my.service"), | ||||
| @@ -28,137 +20,43 @@ service := micro.NewService( | ||||
| ) | ||||
| ``` | ||||
|  | ||||
| ### Simple call | ||||
| ### Call Service | ||||
|  | ||||
| Assuming you have a http service "my.service" with path "/foo/bar" | ||||
| ```go | ||||
| import ( | ||||
|     "go.unistack.org/micro/v3/client" | ||||
|     http "go.unistack.org/micro-client-http/v3" | ||||
|     jsoncodec "go.unistack.org/micro-codec-json/v3" | ||||
| ) | ||||
| // new client | ||||
| client := http.NewClient() | ||||
|  | ||||
| c := http.NewClient( | ||||
|     client.Codec("application/json", jsoncodec.NewCodec()), | ||||
| ) | ||||
| // create request/response | ||||
| request := client.NewRequest("my.service", "/foo/bar", protoRequest{}) | ||||
| response := new(protoResponse) | ||||
|  | ||||
| req := c.NewRequest( | ||||
|     "user-service", | ||||
|     "/user/{user_id}/order/{order_id}", | ||||
|     &protoReq{UserId: "123", OrderId: 456}, | ||||
| ) | ||||
| rsp := new(protoRsp) | ||||
|  | ||||
| err := c.Call( | ||||
|     ctx, | ||||
|     req, | ||||
|     rsp, | ||||
|     client.WithAddress("example.com"), | ||||
| ) | ||||
| // call service | ||||
| err := client.Call(context.TODO(), request, response) | ||||
| ``` | ||||
|  | ||||
| ### Call with specific options | ||||
|  | ||||
| or you can call any rest api or site and unmarshal to response struct | ||||
| ```go | ||||
| import ( | ||||
|     "go.unistack.org/micro/v3/client" | ||||
|     http "go.unistack.org/micro-client-http/v3" | ||||
| ) | ||||
| // new client | ||||
| client := client.NewClientCallOptions(http.NewClient(), http.Address("https://api.github.com")) | ||||
|  | ||||
| err := c.Call( | ||||
|     ctx, | ||||
|     req, | ||||
|     rsp, | ||||
|     client.WithAddress("example.com"), | ||||
|     http.Method("POST"), | ||||
|     http.Path("/user/{user_id}/order/{order_id}"), | ||||
|     http.Body("*"), // <- use all fields from the proto request as HTTP request body or specify a single field name to use only that field (see Google API HTTP spec: google/api/http.proto) | ||||
| ) | ||||
| req := client.NewRequest("github", "/users/vtolstov", nil) | ||||
| rsp := make(map[string]interface{}) | ||||
|  | ||||
| err := c.Call(context.TODO(), req, &rsp, mhttp.Method(http.MethodGet))  | ||||
| ``` | ||||
|  | ||||
| ### Call with request headers | ||||
| Look at http_test.go for detailed use. | ||||
|  | ||||
| ### Encoding | ||||
|  | ||||
| Default protobuf with content-type application/proto | ||||
| ```go | ||||
| import ( | ||||
|     "go.unistack.org/micro/v3/metadata" | ||||
|     http "go.unistack.org/micro-client-http/v3" | ||||
| ) | ||||
|  | ||||
| ctx := metadata.NewOutgoingContext(ctx, metadata.Pairs( | ||||
|     "Authorization", "Bearer token", | ||||
|     "My-Header", "My-Header-Value", | ||||
| )) | ||||
|  | ||||
| err := c.Call( | ||||
|     ctx, | ||||
|     req, | ||||
|     rsp, | ||||
|     http.Header("Authorization", "true", "My-Header", "false"), // <- call option that declares required/optional headers | ||||
| ) | ||||
| client.NewRequest("service", "/path", protoRequest{}) | ||||
| ``` | ||||
|  | ||||
| ### Call with response headers | ||||
|  | ||||
| Json with content-type application/json | ||||
| ```go | ||||
| import ( | ||||
|     "go.unistack.org/micro/v3/metadata" | ||||
|     http "go.unistack.org/micro-client-http/v3" | ||||
| ) | ||||
|  | ||||
| respMetadata := metadata.Metadata{} | ||||
|  | ||||
| err := c.Call( | ||||
|     ctx, | ||||
|     req, | ||||
|     rsp, | ||||
|     client.WithResponseMetadata(&respMetadata), // <- metadata with response headers | ||||
| ) | ||||
| client.NewJsonRequest("service", "/path", jsonRequest{}) | ||||
| ``` | ||||
|  | ||||
| ### Call with cookies | ||||
|  | ||||
| ```go | ||||
| import ( | ||||
|     "go.unistack.org/micro/v3/metadata" | ||||
|     http "go.unistack.org/micro-client-http/v3" | ||||
| ) | ||||
|  | ||||
| ctx := metadata.NewOutgoingContext(context.Background(), metadata.Pairs( | ||||
|     "Cookie", "session_id=abc123; theme=dark", | ||||
| )) | ||||
|  | ||||
| err := c.Call( | ||||
|     ctx, | ||||
|     req, | ||||
|     rsp, | ||||
|     http.Cookie("session_id", "true", "theme", "false"), // <- call option that declares required/optional cookies | ||||
| ) | ||||
| ``` | ||||
|  | ||||
| ### Call with error mapping | ||||
|  | ||||
| ```go | ||||
| import ( | ||||
|     http "go.unistack.org/micro-client-http/v3" | ||||
|     status "go.unistack.org/micro-client-http/v3/status" | ||||
|     jsoncodec "go.unistack.org/micro-codec-json/v3" | ||||
| ) | ||||
|  | ||||
| err := c.Call( | ||||
|     ctx, | ||||
|     req, | ||||
|     rsp, | ||||
|     http.ErrorMap(map[string]any{ | ||||
|         "default": &protoDefaultError{}, // <- default case | ||||
|         "403":     &protoSpecialError{}, // <- key is the HTTP status code that is mapped to this error | ||||
|     }), | ||||
| ) | ||||
|  | ||||
| if err != nil { | ||||
|     s, ok := status.FromError(err) | ||||
|     if !ok {...} | ||||
|      | ||||
|     code    := s.Code()     // HTTP status code | ||||
|     message := s.Message()  // HTTP status text | ||||
|     details := s.Details()  // Error type mapped from ErrorMap | ||||
| } | ||||
| ``` | ||||
|   | ||||
							
								
								
									
										116
									
								
								builder/body.go
									
									
									
									
									
								
							
							
						
						
									
										116
									
								
								builder/body.go
									
									
									
									
									
								
							| @@ -1,116 +0,0 @@ | ||||
| package builder | ||||
|  | ||||
| import ( | ||||
| 	"fmt" | ||||
|  | ||||
| 	"google.golang.org/protobuf/proto" | ||||
| 	"google.golang.org/protobuf/reflect/protoreflect" | ||||
| ) | ||||
|  | ||||
| func buildSingleFieldBody(msg proto.Message, fieldName string) (proto.Message, error) { | ||||
| 	msgReflect := msg.ProtoReflect() | ||||
|  | ||||
| 	fd, found := findFieldByName(msgReflect, fieldName) | ||||
| 	if !found || fd == nil { | ||||
| 		return nil, fmt.Errorf("field %s not found", fieldName) | ||||
| 	} | ||||
| 	if !msgReflect.Has(fd) { | ||||
| 		return nil, fmt.Errorf("field %s is not set", fieldName) | ||||
| 	} | ||||
|  | ||||
| 	val := msgReflect.Get(fd) | ||||
|  | ||||
| 	if fd.Kind() == protoreflect.MessageKind { | ||||
| 		return val.Message().Interface(), nil | ||||
| 	} | ||||
|  | ||||
| 	newMsg := proto.Clone(msg) | ||||
| 	newMsgReflect := newMsg.ProtoReflect() | ||||
| 	newMsgReflect.Range(func(f protoreflect.FieldDescriptor, _ protoreflect.Value) bool { | ||||
| 		if f != fd { | ||||
| 			newMsgReflect.Clear(f) | ||||
| 		} | ||||
| 		return true | ||||
| 	}) | ||||
|  | ||||
| 	return newMsg, nil | ||||
| } | ||||
|  | ||||
| func buildFullBody(msg proto.Message, usedFieldsPath *usedFields) (proto.Message, error) { | ||||
| 	var ( | ||||
| 		msgReflect    = msg.ProtoReflect() | ||||
| 		newMsg        = msgReflect.New().Interface() | ||||
| 		newMsgReflect = newMsg.ProtoReflect() | ||||
| 	) | ||||
|  | ||||
| 	fields := msgReflect.Descriptor().Fields() | ||||
| 	for i := 0; i < fields.Len(); i++ { | ||||
| 		fd := fields.Get(i) | ||||
| 		fieldName := fd.JSONName() | ||||
|  | ||||
| 		if usedFieldsPath.hasTopLevelKey(fieldName) { | ||||
| 			continue | ||||
| 		} | ||||
|  | ||||
| 		val := msgReflect.Get(fd) | ||||
| 		if !val.IsValid() { | ||||
| 			continue | ||||
| 		} | ||||
|  | ||||
| 		// Note: order of the cases is important! | ||||
| 		switch { | ||||
| 		case fd.IsList(): | ||||
| 			list := val.List() | ||||
| 			newList := newMsgReflect.Mutable(fd).List() | ||||
|  | ||||
| 			if fd.Kind() == protoreflect.MessageKind { | ||||
| 				for j := 0; j < list.Len(); j++ { | ||||
| 					elem, err := buildFullBody(list.Get(j).Message().Interface(), usedFieldsPath) | ||||
| 					if err != nil { | ||||
| 						return nil, fmt.Errorf("recursive build full body: %w", err) | ||||
| 					} | ||||
| 					newList.Append(protoreflect.ValueOfMessage(elem.ProtoReflect())) | ||||
| 				} | ||||
| 			} else { | ||||
| 				for j := 0; j < list.Len(); j++ { | ||||
| 					newList.Append(list.Get(j)) | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 		case fd.IsMap(): | ||||
| 			var ( | ||||
| 				m        = val.Map() | ||||
| 				newMap   = newMsgReflect.Mutable(fd).Map() | ||||
| 				rangeErr error | ||||
| 			) | ||||
| 			m.Range(func(k protoreflect.MapKey, v protoreflect.Value) bool { | ||||
| 				if fd.MapValue().Kind() == protoreflect.MessageKind { | ||||
| 					elem, err := buildFullBody(v.Message().Interface(), usedFieldsPath) | ||||
| 					if err != nil { | ||||
| 						rangeErr = fmt.Errorf("recursive build full body: %w", err) | ||||
| 						return false | ||||
| 					} | ||||
| 					newMap.Set(k, protoreflect.ValueOfMessage(elem.ProtoReflect())) | ||||
| 				} else { | ||||
| 					newMap.Set(k, v) | ||||
| 				} | ||||
| 				return true | ||||
| 			}) | ||||
| 			if rangeErr != nil { | ||||
| 				return nil, fmt.Errorf("map range error: %w", rangeErr) | ||||
| 			} | ||||
|  | ||||
| 		case fd.Kind() == protoreflect.MessageKind: | ||||
| 			elem, err := buildFullBody(val.Message().Interface(), usedFieldsPath) | ||||
| 			if err != nil { | ||||
| 				return nil, fmt.Errorf("recursive build full body: %w", err) | ||||
| 			} | ||||
| 			newMsgReflect.Set(fd, protoreflect.ValueOfMessage(elem.ProtoReflect())) | ||||
|  | ||||
| 		default: | ||||
| 			newMsgReflect.Set(fd, val) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return newMsg, nil | ||||
| } | ||||
| @@ -1,22 +0,0 @@ | ||||
| package builder | ||||
|  | ||||
| const ( | ||||
| 	singleWildcard string = "*" | ||||
| 	doubleWildcard string = "**" | ||||
| ) | ||||
|  | ||||
| type bodyOption string | ||||
|  | ||||
| func (o bodyOption) String() string { return string(o) } | ||||
|  | ||||
| func (o bodyOption) isFullBody() bool { | ||||
| 	return o.String() == singleWildcard | ||||
| } | ||||
|  | ||||
| func (o bodyOption) isWithoutBody() bool { | ||||
| 	return o == "" | ||||
| } | ||||
|  | ||||
| func (o bodyOption) isSingleField() bool { | ||||
| 	return o != "" && o.String() != singleWildcard | ||||
| } | ||||
| @@ -1,79 +0,0 @@ | ||||
| package builder | ||||
|  | ||||
| import ( | ||||
| 	"testing" | ||||
|  | ||||
| 	"github.com/stretchr/testify/require" | ||||
| ) | ||||
|  | ||||
| func TestBodyOption_String(t *testing.T) { | ||||
| 	tests := []struct { | ||||
| 		name string | ||||
| 		opt  bodyOption | ||||
| 		want string | ||||
| 	}{ | ||||
| 		{"empty", bodyOption(""), ""}, | ||||
| 		{"star", bodyOption("*"), "*"}, | ||||
| 		{"field", bodyOption("field"), "field"}, | ||||
| 	} | ||||
|  | ||||
| 	for _, tt := range tests { | ||||
| 		t.Run(tt.name, func(t *testing.T) { | ||||
| 			require.Equal(t, tt.want, tt.opt.String()) | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestBodyOption_isFullBody(t *testing.T) { | ||||
| 	tests := []struct { | ||||
| 		name string | ||||
| 		opt  bodyOption | ||||
| 		want bool | ||||
| 	}{ | ||||
| 		{"empty", bodyOption(""), false}, | ||||
| 		{"star", bodyOption("*"), true}, | ||||
| 		{"field", bodyOption("field"), false}, | ||||
| 	} | ||||
|  | ||||
| 	for _, tt := range tests { | ||||
| 		t.Run(tt.name, func(t *testing.T) { | ||||
| 			require.Equal(t, tt.want, tt.opt.isFullBody()) | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestBodyOption_isWithoutBody(t *testing.T) { | ||||
| 	tests := []struct { | ||||
| 		name string | ||||
| 		opt  bodyOption | ||||
| 		want bool | ||||
| 	}{ | ||||
| 		{"empty", bodyOption(""), true}, | ||||
| 		{"star", bodyOption("*"), false}, | ||||
| 		{"field", bodyOption("field"), false}, | ||||
| 	} | ||||
|  | ||||
| 	for _, tt := range tests { | ||||
| 		t.Run(tt.name, func(t *testing.T) { | ||||
| 			require.Equal(t, tt.want, tt.opt.isWithoutBody()) | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestBodyOption_isSingleField(t *testing.T) { | ||||
| 	tests := []struct { | ||||
| 		name string | ||||
| 		opt  bodyOption | ||||
| 		want bool | ||||
| 	}{ | ||||
| 		{"empty", bodyOption(""), false}, | ||||
| 		{"star", bodyOption("*"), false}, | ||||
| 		{"field", bodyOption("field"), true}, | ||||
| 	} | ||||
|  | ||||
| 	for _, tt := range tests { | ||||
| 		t.Run(tt.name, func(t *testing.T) { | ||||
| 			require.Equal(t, tt.want, tt.opt.isSingleField()) | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
| @@ -1,114 +0,0 @@ | ||||
| package builder | ||||
|  | ||||
| import ( | ||||
| 	"encoding/base64" | ||||
| 	"fmt" | ||||
| 	"strconv" | ||||
| 	"strings" | ||||
|  | ||||
| 	"google.golang.org/protobuf/reflect/protoreflect" | ||||
| ) | ||||
|  | ||||
| // findFieldByPath resolves a dot-separated field path in a protobuf message and returns the protoreflect value and its descriptor. | ||||
| func findFieldByPath(msg protoreflect.Message, fieldPath string) (protoreflect.Value, protoreflect.FieldDescriptor, bool) { | ||||
| 	var ( | ||||
| 		current    = msg | ||||
| 		parts      = strings.Split(fieldPath, ".") | ||||
| 		partsCount = len(parts) - 1 | ||||
| 	) | ||||
|  | ||||
| 	for i, part := range parts { | ||||
| 		fd, ok := findFieldByName(current, part) | ||||
| 		if !ok { | ||||
| 			return protoreflect.Value{}, nil, false | ||||
| 		} | ||||
|  | ||||
| 		val := current.Get(fd) | ||||
| 		if i == partsCount { // it's last part | ||||
| 			return val, fd, true | ||||
| 		} | ||||
|  | ||||
| 		if fd.Kind() != protoreflect.MessageKind { | ||||
| 			return protoreflect.Value{}, nil, false | ||||
| 		} | ||||
| 		current = val.Message() | ||||
| 	} | ||||
|  | ||||
| 	return protoreflect.Value{}, nil, false | ||||
| } | ||||
|  | ||||
| // findFieldByName find a field name in a protobuf message and returns the protoreflect field descriptor. | ||||
| func findFieldByName(msg protoreflect.Message, fieldName string) (protoreflect.FieldDescriptor, bool) { | ||||
| 	fields := msg.Descriptor().Fields() | ||||
| 	for i := 0; i < fields.Len(); i++ { | ||||
| 		fd := fields.Get(i) | ||||
| 		if fd.JSONName() == fieldName { | ||||
| 			return fd, true | ||||
| 		} | ||||
| 	} | ||||
| 	return nil, false | ||||
| } | ||||
|  | ||||
| // isZeroValue checks if protoreflect.Value is zero for the field. | ||||
| func isZeroValue(val protoreflect.Value, fd protoreflect.FieldDescriptor) bool { | ||||
| 	if fd.IsList() { | ||||
| 		return val.List().Len() == 0 | ||||
| 	} | ||||
| 	if fd.IsMap() { | ||||
| 		return val.Map().Len() == 0 | ||||
| 	} | ||||
|  | ||||
| 	switch fd.Kind() { | ||||
| 	case protoreflect.StringKind: | ||||
| 		return val.String() == "" | ||||
| 	case protoreflect.BytesKind: | ||||
| 		return len(val.Bytes()) == 0 | ||||
| 	case protoreflect.BoolKind: | ||||
| 		return !val.Bool() | ||||
| 	case protoreflect.Int32Kind, protoreflect.Sint32Kind, protoreflect.Sfixed32Kind, | ||||
| 		protoreflect.Int64Kind, protoreflect.Sint64Kind, protoreflect.Sfixed64Kind: | ||||
| 		return val.Int() == 0 | ||||
| 	case protoreflect.Uint32Kind, protoreflect.Fixed32Kind, | ||||
| 		protoreflect.Uint64Kind, protoreflect.Fixed64Kind: | ||||
| 		return val.Uint() == 0 | ||||
| 	case protoreflect.FloatKind, protoreflect.DoubleKind: | ||||
| 		return val.Float() == 0 | ||||
| 	case protoreflect.EnumKind: | ||||
| 		return val.Enum() == 0 | ||||
| 	case protoreflect.MessageKind: | ||||
| 		return !val.Message().IsValid() | ||||
| 	default: | ||||
| 		return !val.IsValid() | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // stringifyValue converts protoreflect.Value to string for path/query substitution. | ||||
| func stringifyValue(val protoreflect.Value, fd protoreflect.FieldDescriptor) (string, error) { | ||||
| 	switch fd.Kind() { | ||||
| 	case protoreflect.StringKind: | ||||
| 		return val.String(), nil | ||||
| 	case protoreflect.BytesKind: | ||||
| 		return base64.StdEncoding.EncodeToString(val.Bytes()), nil | ||||
| 	case protoreflect.BoolKind: | ||||
| 		if val.Bool() { | ||||
| 			return "true", nil | ||||
| 		} | ||||
| 		return "false", nil | ||||
| 	case protoreflect.Int32Kind, protoreflect.Sint32Kind, protoreflect.Sfixed32Kind, | ||||
| 		protoreflect.Int64Kind, protoreflect.Sint64Kind, protoreflect.Sfixed64Kind: | ||||
| 		return fmt.Sprintf("%d", val.Int()), nil | ||||
| 	case protoreflect.Uint32Kind, protoreflect.Fixed32Kind, | ||||
| 		protoreflect.Uint64Kind, protoreflect.Fixed64Kind: | ||||
| 		return fmt.Sprintf("%d", val.Uint()), nil | ||||
| 	case protoreflect.FloatKind, protoreflect.DoubleKind: | ||||
| 		return strconv.FormatFloat(val.Float(), 'g', -1, 64), nil | ||||
| 	case protoreflect.EnumKind: | ||||
| 		ed := fd.Enum().Values().ByNumber(val.Enum()) | ||||
| 		if ed != nil { | ||||
| 			return string(ed.Name()), nil | ||||
| 		} | ||||
| 		return fmt.Sprintf("%d", val.Enum()), nil | ||||
| 	default: | ||||
| 		return "", fmt.Errorf("unsupported field kind: %s", fd.Kind()) | ||||
| 	} | ||||
| } | ||||
| @@ -1,313 +0,0 @@ | ||||
| package builder | ||||
|  | ||||
| import ( | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"net/url" | ||||
| 	"strings" | ||||
|  | ||||
| 	"google.golang.org/protobuf/proto" | ||||
| 	"google.golang.org/protobuf/reflect/protoreflect" | ||||
| ) | ||||
|  | ||||
| // -------------------------- Path template representation ------------------------- | ||||
|  | ||||
| // pathSegment is a helper interface for elements of a path. | ||||
| type pathSegment interface { | ||||
| 	isSegment() bool | ||||
| } | ||||
|  | ||||
| // pathTemplate represents a parsed URL path template. | ||||
| type pathTemplate struct { | ||||
| 	// literalPrefix is the fixed part of the path before the first {var}, | ||||
| 	// e.g. "/v1/users/" for "/v1/users/{user_id}/orders:get". | ||||
| 	// It is removed from segments, so segments contain only the remaining path literals and variables. | ||||
| 	literalPrefix string | ||||
|  | ||||
| 	// segments is a sequence of pathLiteral or pathVar representing the rest of the path after literalPrefix. | ||||
| 	segments []pathSegment | ||||
|  | ||||
| 	// customVerb is an optional ":verb" suffix, e.g. ":get". | ||||
| 	customVerb string | ||||
| } | ||||
|  | ||||
| // pathLiteral represents a fixed literal segment in a path template, e.g., "/v1/users/". | ||||
| type pathLiteral struct { | ||||
| 	text string | ||||
| } | ||||
|  | ||||
| func (p pathLiteral) isSegment() bool { return true } | ||||
|  | ||||
| // pathVar represents a variable segment in a path template, e.g., "{user.id}". | ||||
| type pathVar struct { | ||||
| 	// fieldPath is the dotted path to the field in the struct, e.g., "user.id". | ||||
| 	fieldPath string | ||||
|  | ||||
| 	// pattern is the optional pattern after '=', e.g., "*" or "**/orders". | ||||
| 	// It specifies how the variable can match parts of the URL path. | ||||
| 	pattern string | ||||
|  | ||||
| 	// multiSegment is true if the pattern can match multiple path segments | ||||
| 	// (contains '/' or "**"). | ||||
| 	multiSegment bool | ||||
| } | ||||
|  | ||||
| func (p pathVar) isSegment() bool { return true } | ||||
|  | ||||
| // ----------------------------- Path template parsing ----------------------------- | ||||
|  | ||||
| // parsePathTemplate parses a URL path template into a pathTemplate. | ||||
| // It extracts: | ||||
| //  1. literalPrefix — fixed part before the first variable, | ||||
| //  2. segments — sequence of pathLiteral and pathVar, | ||||
| //  3. customVerb — optional ":verb" suffix. | ||||
| // | ||||
| // Complexity: time O(n), memory O(n). | ||||
| // | ||||
| // Example: | ||||
| // | ||||
| //	input: "/v1/users/{user_id}/orders:get" | ||||
| //	output: pathTemplate{ | ||||
| //	  literalPrefix: "/v1/users/", | ||||
| //	  segments:      [{user_id}, "/orders"], | ||||
| //	  customVerb:    ":get", | ||||
| //	} | ||||
| func parsePathTemplate(input string) (*pathTemplate, error) { | ||||
| 	// Step 1: extract custom verb after the last colon, e.g. ":get" | ||||
| 	var customVerb string | ||||
| 	if i := strings.LastIndex(input, ":"); i >= 0 && i > strings.LastIndex(input, "/") { | ||||
| 		customVerb = input[i:] | ||||
| 		input = input[:i] | ||||
| 	} | ||||
|  | ||||
| 	var ( | ||||
| 		segments []pathSegment | ||||
| 		buf      strings.Builder | ||||
| 	) | ||||
|  | ||||
| 	// Step 2: iterate over the input and split into segments | ||||
| 	for i := 0; i < len(input); { | ||||
| 		if input[i] != '{' { | ||||
| 			buf.WriteByte(input[i]) | ||||
| 			i++ | ||||
| 			continue | ||||
| 		} | ||||
|  | ||||
| 		// Add literal before '{' if any | ||||
| 		if buf.Len() > 0 { | ||||
| 			segments = append(segments, pathLiteral{text: buf.String()}) | ||||
| 			buf.Reset() | ||||
| 		} | ||||
|  | ||||
| 		// Find closing '}' | ||||
| 		start := i + 1 | ||||
| 		offset := strings.IndexByte(input[start:], '}') // relative offset from start | ||||
| 		if offset < 0 { | ||||
| 			return nil, fmt.Errorf("unclosed '{' in path: %s", input) | ||||
| 		} | ||||
| 		end := start + offset | ||||
|  | ||||
| 		token := input[start:end] | ||||
| 		i = end + 1 // jump past '}' | ||||
|  | ||||
| 		// Split field path and optional pattern | ||||
| 		var fieldPath, pattern string | ||||
| 		if k := strings.IndexByte(token, '='); k >= 0 { | ||||
| 			fieldPath = strings.TrimSpace(token[:k]) | ||||
| 			pattern = strings.TrimSpace(token[k+1:]) | ||||
| 		} else { | ||||
| 			fieldPath = strings.TrimSpace(token) | ||||
| 		} | ||||
|  | ||||
| 		if fieldPath == "" { | ||||
| 			return nil, fmt.Errorf("empty variable in path: %s", input) | ||||
| 		} | ||||
|  | ||||
| 		pv := pathVar{ | ||||
| 			fieldPath:    fieldPath, | ||||
| 			pattern:      pattern, | ||||
| 			multiSegment: isMultiSegmentPattern(pattern), | ||||
| 		} | ||||
| 		segments = append(segments, pv) | ||||
| 	} | ||||
|  | ||||
| 	// Step 3: add any trailing literal after last '}' | ||||
| 	if buf.Len() > 0 { | ||||
| 		segments = append(segments, pathLiteral{text: buf.String()}) | ||||
| 	} | ||||
|  | ||||
| 	// Step 4: extract literalPrefix if the first segment is a literal | ||||
| 	var literalPrefix string | ||||
| 	if len(segments) > 0 { | ||||
| 		if pl, ok := segments[0].(pathLiteral); ok { | ||||
| 			literalPrefix = pl.text | ||||
| 			segments = segments[1:] // remove from segments to avoid duplication | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// Step 5: return fully parsed pathTemplate | ||||
| 	return &pathTemplate{ | ||||
| 		literalPrefix: literalPrefix, | ||||
| 		segments:      segments, | ||||
| 		customVerb:    customVerb, | ||||
| 	}, nil | ||||
| } | ||||
|  | ||||
| // isMultiSegmentPattern returns true if pattern can match multiple path segments (contains '/' or '**'). | ||||
| // Examples: | ||||
| // | 	Pattern     | Result | 					Usecase					| | ||||
| // |----------------|--------|------------------------------------------| | ||||
| // | ""             | false  | {var} 			=> single segment		| | ||||
| // | "*"            | false  | {var=*} 			=> single segment		| | ||||
| // | "**"           | true   | {var=**} 		=> multiple segments	| | ||||
| // | "foo/*"        | true   | {var=foo/*} 		=> multiple segments	| | ||||
| // | "foo/**"       | true   | {var=foo/**} 	=> multiple segments	| | ||||
| // | "users/*/orders"| true  | {users/*/orders} => multiple segments	| | ||||
| func isMultiSegmentPattern(pattern string) bool { | ||||
| 	if pattern == "" { | ||||
| 		return false | ||||
| 	} | ||||
| 	if pattern == singleWildcard { | ||||
| 		return false | ||||
| 	} | ||||
| 	return strings.Contains(pattern, "/") || strings.Contains(pattern, doubleWildcard) | ||||
| } | ||||
|  | ||||
| // ----------------------------- Path template resolving ----------------------------- | ||||
|  | ||||
| // resolvePathPlaceholders expands placeholders in a path template using values from proto.Message. | ||||
| // Placeholders must be bound to non-repeated scalar fields (not lists, maps, or messages). | ||||
| // | ||||
| // Example: | ||||
| // | ||||
| //	tmpl:       "/v1/users/{user_id}/orders:get" | ||||
| //	msg:        &pb.Message{UserId: 12345} | ||||
| // | ||||
| //	path:       "/v1/users/12345/orders:get" | ||||
| //	usedFields: {"user_id"} | ||||
| func resolvePathPlaceholders(tmpl *pathTemplate, msg proto.Message) (path string, usedFields *usedFields, err error) { | ||||
| 	usedFields = newUsedFields() | ||||
|  | ||||
| 	var sb strings.Builder | ||||
| 	sb.WriteString(tmpl.literalPrefix) | ||||
|  | ||||
| 	msgReflect := msg.ProtoReflect() | ||||
|  | ||||
| 	for _, segment := range tmpl.segments { | ||||
| 		switch s := segment.(type) { | ||||
| 		case pathLiteral: | ||||
| 			sb.WriteString(s.text) | ||||
|  | ||||
| 		case pathVar: | ||||
| 			val, fd, ok := findFieldByPath(msgReflect, s.fieldPath) | ||||
| 			if !ok { | ||||
| 				return "", nil, fmt.Errorf("path placeholder %s not found", s.fieldPath) | ||||
| 			} | ||||
| 			if isZeroValue(val, fd) { | ||||
| 				// it's the only case that allows zero-value matches. | ||||
| 				if s.pattern == doubleWildcard { | ||||
| 					usedFields.add(s.fieldPath) | ||||
| 					continue | ||||
| 				} | ||||
| 				return "", nil, fmt.Errorf("path placeholder %s has zero value", s.fieldPath) | ||||
| 			} | ||||
|  | ||||
| 			// must be scalar (non-repeated, non-map, non-message) | ||||
| 			if fd.IsList() || fd.IsMap() || fd.Kind() == protoreflect.MessageKind { | ||||
| 				return "", nil, fmt.Errorf("path placeholder %s must be scalar", s.fieldPath) | ||||
| 			} | ||||
|  | ||||
| 			usedFields.add(s.fieldPath) | ||||
|  | ||||
| 			var strVal string | ||||
| 			strVal, err = stringifyValue(val, fd) | ||||
| 			if err != nil { | ||||
| 				return "", nil, fmt.Errorf("stringify placeholder %s: %w", s.fieldPath, err) | ||||
| 			} | ||||
|  | ||||
| 			if err = validatePattern(s.pattern, strVal); err != nil { | ||||
| 				return "", nil, fmt.Errorf("validate pattern, %s:%s: %w", s.fieldPath, strVal, err) | ||||
| 			} | ||||
|  | ||||
| 			parts := strings.Split(strVal, "/") | ||||
| 			for i := range parts { | ||||
| 				parts[i] = url.PathEscape(parts[i]) | ||||
| 			} | ||||
| 			sb.WriteString(strings.Join(parts, "/")) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	sb.WriteString(tmpl.customVerb) | ||||
| 	return sb.String(), usedFields, nil | ||||
| } | ||||
|  | ||||
| // validatePattern checks whether input matches the given path pattern. | ||||
| // | ||||
| // Rules: | ||||
| //   - "" or "*" => exactly one segment, no "/" allowed | ||||
| //   - "**"      => zero or more segments (may include "/") | ||||
| //   - composite patterns like "*/orders/*" must match literally | ||||
| // | ||||
| // Example for composite pattern case: | ||||
| // | ||||
| //	pattern: "*/orders/*" | ||||
| //	input:   "42/orders/123" | ||||
| // | ||||
| //	patternSegments = ["*", "orders", "*"] | ||||
| //	valueParts      = ["42", "orders", "123"] | ||||
| // | ||||
| //	Match: | ||||
| //	  "*"      -> "42" | ||||
| //	  "orders" -> "orders" | ||||
| //	  "*"      -> "123" | ||||
| func validatePattern(pattern, input string) error { | ||||
| 	var ( | ||||
| 		parts    = strings.Split(input, "/") | ||||
| 		lenParts = len(parts) | ||||
| 	) | ||||
|  | ||||
| 	if pattern == "" || pattern == singleWildcard { | ||||
| 		if lenParts != 1 { | ||||
| 			return errors.New("must be a single path segment") | ||||
| 		} | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| 	if pattern == doubleWildcard { | ||||
| 		if lenParts < 1 { | ||||
| 			return errors.New("must contain at least one segment") | ||||
| 		} | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| 	var ( | ||||
| 		patternSegments = strings.Split(pattern, "/") | ||||
| 		patternIndex    int | ||||
| 	) | ||||
|  | ||||
| 	for i := 0; i < len(patternSegments); i++ { | ||||
| 		switch patternSegments[i] { | ||||
| 		case singleWildcard: | ||||
| 			if patternIndex >= lenParts || parts[patternIndex] == "" { | ||||
| 				return fmt.Errorf("segment %d must not be empty", patternIndex) | ||||
| 			} | ||||
| 			patternIndex++ | ||||
| 		case doubleWildcard: | ||||
| 			if patternIndex >= lenParts { | ||||
| 				return fmt.Errorf("must contain at least one segment at position %d", patternIndex) | ||||
| 			} | ||||
| 			return nil | ||||
| 		default: | ||||
| 			if patternIndex >= lenParts || parts[patternIndex] != patternSegments[i] { | ||||
| 				return fmt.Errorf("expected literal %s at position %d", patternSegments[i], patternIndex) | ||||
| 			} | ||||
| 			patternIndex++ | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	if patternIndex != lenParts { | ||||
| 		return errors.New("extra segments in value") | ||||
| 	} | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
| @@ -1,21 +0,0 @@ | ||||
| package builder | ||||
|  | ||||
| import "sync" | ||||
|  | ||||
| var ( | ||||
| 	pathTemplateCache   = make(map[string]*pathTemplate) | ||||
| 	pathTemplateCacheMu sync.RWMutex | ||||
| ) | ||||
|  | ||||
| func getCachedPathTemplate(path string) (*pathTemplate, bool) { | ||||
| 	pathTemplateCacheMu.RLock() | ||||
| 	defer pathTemplateCacheMu.RUnlock() | ||||
| 	tmpl, ok := pathTemplateCache[path] | ||||
| 	return tmpl, ok | ||||
| } | ||||
|  | ||||
| func setPathTemplateCache(path string, tmpl *pathTemplate) { | ||||
| 	pathTemplateCacheMu.Lock() | ||||
| 	defer pathTemplateCacheMu.Unlock() | ||||
| 	pathTemplateCache[path] = tmpl | ||||
| } | ||||
| @@ -1,15 +0,0 @@ | ||||
| package proto | ||||
|  | ||||
| import "google.golang.org/protobuf/encoding/protojson" | ||||
|  | ||||
| var marshaler = protojson.MarshalOptions{} | ||||
|  | ||||
| func (m *Test_Client_Call_DefaultError) Error() string { | ||||
| 	buf, _ := marshaler.Marshal(m) | ||||
| 	return string(buf) | ||||
| } | ||||
|  | ||||
| func (m *Test_Client_Call_SpecialError) Error() string { | ||||
| 	buf, _ := marshaler.Marshal(m) | ||||
| 	return string(buf) | ||||
| } | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,330 +0,0 @@ | ||||
| syntax = "proto3"; | ||||
|  | ||||
| package proto; | ||||
|  | ||||
| option go_package = "go.unistack.org/micro-client-http/v3/proto;proto"; | ||||
|  | ||||
| message TestRequestBuilder {} | ||||
|  | ||||
| message Test_PathOnly { | ||||
|   message PrimitiveCase { | ||||
|     string userId = 1 [json_name = "user_id"]; | ||||
|     int64  orderId = 2 [json_name = "order_id"]; | ||||
|   } | ||||
|  | ||||
|   message NestedCase { | ||||
|     User user = 1; | ||||
|     Order order = 2; | ||||
|  | ||||
|     message User { | ||||
|       string id = 1; | ||||
|     } | ||||
|     message Order { | ||||
|       int64 id = 1; | ||||
|       Product product = 2; | ||||
|  | ||||
|       message Product { | ||||
|         int64 id = 1; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   message MultipleCase { | ||||
|     string userId = 1 [json_name = "user_id"]; | ||||
|     Order order = 2; | ||||
|  | ||||
|     message Order { | ||||
|       string id = 1; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   message RepeatedCase { | ||||
|     repeated string userId = 1 [json_name = "user_id"]; | ||||
|     int64  orderId = 2 [json_name = "order_id"]; | ||||
|   } | ||||
|  | ||||
|   message NonPrimitiveMessageCase { | ||||
|     User userId = 1 [json_name = "user_id"]; | ||||
|     int64  orderId = 2 [json_name = "order_id"]; | ||||
|  | ||||
|     message User { | ||||
|       string id = 1; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   message NonPrimitiveMapCase { | ||||
|     map<string, string> userId = 1 [json_name = "user_id"]; | ||||
|     int64  orderId = 2 [json_name = "order_id"]; | ||||
|   } | ||||
|  | ||||
|   message PatternCase { | ||||
|     string pattern = 1; | ||||
|   } | ||||
|  | ||||
|   message CompositePatternCase { | ||||
|     string pattern = 1; | ||||
|     string orderId = 2 [json_name = "order_id"]; | ||||
|     string productId = 3 [json_name = "product_id"]; | ||||
|   } | ||||
| } | ||||
|  | ||||
| message Test_QueryOnly { | ||||
|   message PrimitiveCase { | ||||
|     string userId = 1 [json_name = "user_id"]; | ||||
|     int64  orderId = 2 [json_name = "order_id"]; | ||||
|     bool flag = 3; | ||||
|   } | ||||
|  | ||||
|   message RepeatedCase { | ||||
|     repeated string strings = 1; | ||||
|     repeated int64 integers = 2; | ||||
|   } | ||||
|  | ||||
|   message NestedMessageCase { | ||||
|     string userId = 1 [json_name = "user_id"]; | ||||
|     Filter filter = 2; | ||||
|  | ||||
|     message Filter { | ||||
|       int64 age = 1; | ||||
|       string name = 2; | ||||
|       SubFilter subFilter = 3 [json_name = "sub_filter"]; | ||||
|  | ||||
|       message SubFilter { | ||||
|         int64 subAge = 1 [json_name = "sub_age"]; | ||||
|         string subName = 2 [json_name = "sub_name"]; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   message NestedMapCase { | ||||
|     string userId = 1 [json_name = "user_id"]; | ||||
|     map<string, string> firstFilter = 2 [json_name = "first_filter"]; | ||||
|     map<string, SubFilter> secondFilter = 4 [json_name = "second_filter"]; | ||||
|     message SubFilter { | ||||
|       int64 subAge = 1 [json_name = "sub_age"]; | ||||
|       string subName = 2 [json_name = "sub_name"]; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   message MultipleCase { | ||||
|     string userId = 1 [json_name = "user_id"]; | ||||
|     repeated string strings = 2; | ||||
|     Filter firstFilter = 3 [json_name = "first_filter"]; | ||||
|     map<string, SubFilter> secondFilter = 4 [json_name = "second_filter"]; | ||||
|  | ||||
|     message Filter { | ||||
|       int64 age = 1; | ||||
|       SubFilter subFilter = 2 [json_name = "sub_filter"]; | ||||
|     } | ||||
|     message SubFilter { | ||||
|       int64 subAge = 1 [json_name = "sub_age"]; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   message RepeatedMessageCase { | ||||
|     repeated Filter filters = 1; | ||||
|     message Filter { | ||||
|       int64 age = 1; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| message Test_BodyOnly { | ||||
|   message PrimitiveCase { | ||||
|     string userId = 1 [json_name = "user_id"]; | ||||
|     int64  orderId = 2 [json_name = "order_id"]; | ||||
|     bool flag = 3; | ||||
|     repeated string strings = 4; | ||||
|     Product product = 6; | ||||
|  | ||||
|     message Product { | ||||
|       string id = 1; | ||||
|       string name = 2; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   message NestedCase { | ||||
|     string userId = 1 [json_name = "user_id"]; | ||||
|     Filter first_filter = 2 [json_name = "first_filter"]; | ||||
|     Filter second_filter = 3 [json_name = "second_filter"]; | ||||
|  | ||||
|     message Filter { | ||||
|       int64 age = 1; | ||||
|       string name = 2; | ||||
|       SubFilter subFilter = 3 [json_name = "sub_filter"]; | ||||
|  | ||||
|       message SubFilter { | ||||
|         int64 subAge = 1 [json_name = "sub_age"]; | ||||
|         string subName = 2 [json_name = "sub_name"]; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   message RepeatedMessageCase { | ||||
|     string userId = 1 [json_name = "user_id"]; | ||||
|     repeated Product products = 2 [json_name = "products"]; | ||||
|  | ||||
|     message Product { | ||||
|       string id = 1; | ||||
|       string name = 2; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   message MapCase { | ||||
|     map<string, string> firstFilter = 1 [json_name = "first_filter"]; | ||||
|     map<string, SubFilter> secondFilter = 2 [json_name = "second_filter"]; | ||||
|  | ||||
|     message SubFilter { | ||||
|       int64 subAge = 1 [json_name = "sub_age"]; | ||||
|       string subName = 2 [json_name = "sub_name"]; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   message MultipleCase { | ||||
|     string userId = 1 [json_name = "user_id"]; | ||||
|     repeated SubFilter firstFilter = 2 [json_name = "first_filter"]; | ||||
|     map<string, SubFilter> secondFilter = 3 [json_name = "second_filter"]; | ||||
|     SubFilter thirdFilter = 4 [json_name = "third_filter"]; | ||||
|  | ||||
|     message SubFilter { | ||||
|       int64 subAge = 1 [json_name = "sub_age"]; | ||||
|       string subName = 2 [json_name = "sub_name"]; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| message Test_Mixed { | ||||
|   message PrimitiveCase { | ||||
|     string userId = 1 [json_name = "user_id"]; | ||||
|     int64  orderId = 2 [json_name = "order_id"]; | ||||
|     Product product = 3; | ||||
|  | ||||
|     message Product { | ||||
|       string id = 1; | ||||
|       string name = 2; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   message NestedCase { | ||||
|     string userId = 1 [json_name = "user_id"]; | ||||
|     Filter first_filter = 2 [json_name = "first_filter"]; | ||||
|     Filter second_filter = 3 [json_name = "second_filter"]; | ||||
|  | ||||
|     message Filter { | ||||
|       int64 age = 1; | ||||
|       string name = 2; | ||||
|       SubFilter subFilter = 3 [json_name = "sub_filter"]; | ||||
|  | ||||
|       message SubFilter { | ||||
|         int64 subAge = 1 [json_name = "sub_age"]; | ||||
|         string subName = 2 [json_name = "sub_name"]; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   message RepeatedMessageCase { | ||||
|     string userId = 1 [json_name = "user_id"]; | ||||
|     repeated Product products = 2 [json_name = "products"]; | ||||
|  | ||||
|     message Product { | ||||
|       string id = 1; | ||||
|       string name = 2; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   message MapCase { | ||||
|     map<string, string> firstFilter = 1 [json_name = "first_filter"]; | ||||
|     map<string, SubFilter> secondFilter = 2 [json_name = "second_filter"]; | ||||
|  | ||||
|     message SubFilter { | ||||
|       int64 subAge = 1 [json_name = "sub_age"]; | ||||
|       string subName = 2 [json_name = "sub_name"]; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   message MultipleCase { | ||||
|     string userId = 1 [json_name = "user_id"]; | ||||
|     repeated SubFilter firstFilter = 2 [json_name = "first_filter"]; | ||||
|     map<string, SubFilter> secondFilter = 3 [json_name = "second_filter"]; | ||||
|     SubFilter thirdFilter = 4 [json_name = "third_filter"]; | ||||
|  | ||||
|     message SubFilter { | ||||
|       int64 subAge = 1 [json_name = "sub_age"]; | ||||
|       string subName = 2 [json_name = "sub_name"]; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| message Benchmark { | ||||
|   message Case5 { | ||||
|     string field1 = 1; | ||||
|     string field2 = 2; | ||||
|     string field3 = 3; | ||||
|     string field4 = 4; | ||||
|     string field5 = 5; | ||||
|   } | ||||
|   message Case10 { | ||||
|     string field1 = 1; | ||||
|     string field2 = 2; | ||||
|     string field3 = 3; | ||||
|     string field4 = 4; | ||||
|     string field5 = 5; | ||||
|     string field6 = 6; | ||||
|     string field7 = 7; | ||||
|     string field8 = 8; | ||||
|     string field9 = 9; | ||||
|     string field10 = 10; | ||||
|   } | ||||
|   message Case30 { | ||||
|     string field1 = 1; | ||||
|     string field2 = 2; | ||||
|     string field3 = 3; | ||||
|     string field4 = 4; | ||||
|     string field5 = 5; | ||||
|     string field6 = 6; | ||||
|     string field7 = 7; | ||||
|     string field8 = 8; | ||||
|     string field9 = 9; | ||||
|     string field10 = 10; | ||||
|     string field11 = 11; | ||||
|     string field12 = 12; | ||||
|     string field13 = 13; | ||||
|     string field14 = 14; | ||||
|     string field15 = 15; | ||||
|     string field16 = 16; | ||||
|     string field17 = 17; | ||||
|     string field18 = 18; | ||||
|     string field19 = 19; | ||||
|     string field20 = 20; | ||||
|     string field21 = 21; | ||||
|     string field22 = 22; | ||||
|     string field23 = 23; | ||||
|     string field24 = 24; | ||||
|     string field25 = 25; | ||||
|     string field26 = 26; | ||||
|     string field27 = 27; | ||||
|     string field28 = 28; | ||||
|     string field29 = 29; | ||||
|     string field30 = 30; | ||||
|   } | ||||
| } | ||||
|  | ||||
| message Test_Client_Call { | ||||
|   message Request { | ||||
|     string userId = 1 [json_name = "user_id"]; | ||||
|     int64  orderId = 2 [json_name = "order_id"]; | ||||
|   } | ||||
|   message Response { | ||||
|     string id = 1; | ||||
|     string name = 2; | ||||
|   } | ||||
|   message DefaultError { | ||||
|     string code = 1; | ||||
|     string msg = 2; | ||||
|   } | ||||
|   message SpecialError { | ||||
|     string code = 1; | ||||
|     string msg = 2; | ||||
|     string warning = 3; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										194
									
								
								builder/query.go
									
									
									
									
									
								
							
							
						
						
									
										194
									
								
								builder/query.go
									
									
									
									
									
								
							| @@ -1,194 +0,0 @@ | ||||
| package builder | ||||
|  | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"net/url" | ||||
|  | ||||
| 	"google.golang.org/protobuf/proto" | ||||
| 	"google.golang.org/protobuf/reflect/protoreflect" | ||||
| ) | ||||
|  | ||||
| func buildQuery(msg proto.Message, usedFieldsPath *usedFields, usedFieldBody string) (url.Values, error) { | ||||
| 	var ( | ||||
| 		query      = url.Values{} | ||||
| 		msgReflect = msg.ProtoReflect() | ||||
| 	) | ||||
|  | ||||
| 	fields := msgReflect.Descriptor().Fields() | ||||
| 	for i := 0; i < fields.Len(); i++ { | ||||
| 		var ( | ||||
| 			fd        = fields.Get(i) | ||||
| 			fieldName = fd.JSONName() | ||||
| 		) | ||||
|  | ||||
| 		if usedFieldsPath.hasFullKey(fieldName) { | ||||
| 			continue | ||||
| 		} | ||||
| 		if fieldName == usedFieldBody { | ||||
| 			continue | ||||
| 		} | ||||
|  | ||||
| 		val := msgReflect.Get(fd) | ||||
| 		if isZeroValue(val, fd) { | ||||
| 			continue | ||||
| 		} | ||||
|  | ||||
| 		// Note: order of the cases is important! | ||||
| 		switch { | ||||
| 		case fd.IsList(): | ||||
| 			if fd.Kind() == protoreflect.MessageKind { | ||||
| 				return nil, fmt.Errorf("repeated message field %s cannot be mapped to URL query parameters", fieldName) | ||||
| 			} | ||||
| 			list := val.List() | ||||
| 			for j := 0; j < list.Len(); j++ { | ||||
| 				strVal, err := stringifyValue(list.Get(j), fd) | ||||
| 				if err != nil { | ||||
| 					return nil, fmt.Errorf("stringify value for query %s: %w", fieldName, err) | ||||
| 				} | ||||
| 				query.Add(fieldName, strVal) | ||||
| 			} | ||||
|  | ||||
| 		case fd.IsMap(): | ||||
| 			var ( | ||||
| 				m        = val.Map() | ||||
| 				rangeErr error | ||||
| 			) | ||||
| 			m.Range(func(k protoreflect.MapKey, v protoreflect.Value) bool { | ||||
| 				key := fmt.Sprintf("%s.%s", fieldName, k.String()) | ||||
|  | ||||
| 				if fd.MapValue().Kind() == protoreflect.MessageKind { | ||||
| 					flattened, err := flattenMsgForQuery(key, v.Message()) | ||||
| 					if err != nil { | ||||
| 						rangeErr = fmt.Errorf("flatten msg for query %s: %w", fieldName, err) | ||||
| 						return false | ||||
| 					} | ||||
| 					for _, item := range flattened { | ||||
| 						if item.val == "" { | ||||
| 							continue | ||||
| 						} | ||||
| 						if usedFieldsPath.hasFullKey(item.key) { | ||||
| 							continue | ||||
| 						} | ||||
| 						query.Add(item.key, item.val) | ||||
| 					} | ||||
| 				} else { | ||||
| 					strVal, err := stringifyValue(v, fd.MapValue()) | ||||
| 					if err != nil { | ||||
| 						rangeErr = fmt.Errorf("stringify value for map %s: %w", fieldName, err) | ||||
| 						return false | ||||
| 					} | ||||
| 					query.Add(key, strVal) | ||||
| 				} | ||||
| 				return true | ||||
| 			}) | ||||
| 			if rangeErr != nil { | ||||
| 				return nil, fmt.Errorf("map range error: %w", rangeErr) | ||||
| 			} | ||||
|  | ||||
| 		case fd.Kind() == protoreflect.MessageKind: | ||||
| 			flattened, err := flattenMsgForQuery(fieldName, val.Message()) | ||||
| 			if err != nil { | ||||
| 				return nil, fmt.Errorf("flatten msg for query %s: %w", fieldName, err) | ||||
| 			} | ||||
| 			for _, item := range flattened { | ||||
| 				if item.val == "" { | ||||
| 					continue | ||||
| 				} | ||||
| 				if usedFieldsPath.hasFullKey(item.key) { | ||||
| 					continue | ||||
| 				} | ||||
| 				query.Add(item.key, item.val) | ||||
| 			} | ||||
|  | ||||
| 		default: | ||||
| 			strVal, err := stringifyValue(val, fd) | ||||
| 			if err != nil { | ||||
| 				return nil, fmt.Errorf("stringify value for primitive %s: %w", fieldName, err) | ||||
| 			} | ||||
| 			query.Add(fieldName, strVal) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return query, nil | ||||
| } | ||||
|  | ||||
| type flattenItem struct { | ||||
| 	key string | ||||
| 	val string | ||||
| } | ||||
|  | ||||
| // flattenMsgForQuery flattens a non-repeated message value under a given prefix. | ||||
| func flattenMsgForQuery(prefix string, msg protoreflect.Message) ([]flattenItem, error) { | ||||
| 	var out []flattenItem | ||||
|  | ||||
| 	fields := msg.Descriptor().Fields() | ||||
| 	for i := 0; i < fields.Len(); i++ { | ||||
| 		var ( | ||||
| 			fd  = fields.Get(i) | ||||
| 			val = msg.Get(fd) | ||||
| 		) | ||||
|  | ||||
| 		if isZeroValue(val, fd) { | ||||
| 			continue | ||||
| 		} | ||||
|  | ||||
| 		key := fmt.Sprintf("%s.%s", prefix, fd.JSONName()) | ||||
|  | ||||
| 		switch { | ||||
| 		case fd.IsList(): | ||||
| 			if fd.Kind() == protoreflect.MessageKind { | ||||
| 				return nil, fmt.Errorf("repeated message field %s cannot be flattened for query", key) | ||||
| 			} | ||||
| 			list := val.List() | ||||
| 			for j := 0; j < list.Len(); j++ { | ||||
| 				strVal, err := stringifyValue(list.Get(j), fd) | ||||
| 				if err != nil { | ||||
| 					return nil, fmt.Errorf("stringify query %s: %w", key, err) | ||||
| 				} | ||||
| 				out = append(out, flattenItem{key: key, val: strVal}) | ||||
| 			} | ||||
|  | ||||
| 		case fd.Kind() == protoreflect.MessageKind: | ||||
| 			nested, err := flattenMsgForQuery(key, val.Message()) | ||||
| 			if err != nil { | ||||
| 				return nil, fmt.Errorf("flatten msg for query %s: %w", key, err) | ||||
| 			} | ||||
| 			out = append(out, nested...) | ||||
|  | ||||
| 		case fd.IsMap(): | ||||
| 			var mapErr error | ||||
| 			val.Map().Range(func(k protoreflect.MapKey, v protoreflect.Value) bool { | ||||
| 				keyStr := k.String() | ||||
|  | ||||
| 				if fd.MapValue().Kind() == protoreflect.MessageKind { | ||||
| 					child, err := flattenMsgForQuery(keyStr, v.Message()) | ||||
| 					if err != nil { | ||||
| 						mapErr = fmt.Errorf("flatten map value %s: %w", key, err) | ||||
| 						return false | ||||
| 					} | ||||
| 					out = append(out, child...) | ||||
| 				} else { | ||||
| 					strVal, err := stringifyValue(v, fd.MapValue()) | ||||
| 					if err != nil { | ||||
| 						mapErr = fmt.Errorf("stringify query %s: %w", keyStr, err) | ||||
| 						return false | ||||
| 					} | ||||
| 					out = append(out, flattenItem{key: keyStr, val: strVal}) | ||||
| 				} | ||||
| 				return true | ||||
| 			}) | ||||
| 			if mapErr != nil { | ||||
| 				return nil, mapErr | ||||
| 			} | ||||
|  | ||||
| 		default: | ||||
| 			strVal, err := stringifyValue(val, fd) | ||||
| 			if err != nil { | ||||
| 				return nil, fmt.Errorf("stringify query %s: %w", key, err) | ||||
| 			} | ||||
| 			out = append(out, flattenItem{key: key, val: strVal}) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return out, nil | ||||
| } | ||||
| @@ -1,180 +0,0 @@ | ||||
| // Package builder implements google.api.http-style request building (gRPC JSON transcoding) | ||||
| // for HTTP requests, closely following the google.api.http spec. | ||||
| // See full spec for details: https://github.com/googleapis/googleapis/blob/master/google/api/http.proto | ||||
| package builder | ||||
|  | ||||
| import ( | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"net/http" | ||||
| 	"net/url" | ||||
| 	"strings" | ||||
|  | ||||
| 	"google.golang.org/protobuf/proto" | ||||
| 	"google.golang.org/protobuf/reflect/protoreflect" | ||||
| ) | ||||
|  | ||||
| type RequestBuilder struct { | ||||
| 	path       string        // e.g. "/v1/{name=projects/*/topics/*}:publish" or "/users/{user.id}" | ||||
| 	method     string        // GET, POST, PATCH, etc. (not used in mapping rules, but convenient for callers) | ||||
| 	bodyOption bodyOption    // "", "*", or top-level field name | ||||
| 	msg        proto.Message // request struct | ||||
| } | ||||
|  | ||||
| func NewRequestBuilder( | ||||
| 	path string, | ||||
| 	method string, | ||||
| 	bodyOpt string, | ||||
| 	msg proto.Message, | ||||
| ) ( | ||||
| 	*RequestBuilder, | ||||
| 	error, | ||||
| ) { | ||||
| 	rb := &RequestBuilder{ | ||||
| 		path:       path, | ||||
| 		method:     method, | ||||
| 		bodyOption: bodyOption(bodyOpt), | ||||
| 		msg:        msg, | ||||
| 	} | ||||
|  | ||||
| 	if err := rb.validate(); err != nil { | ||||
| 		return nil, fmt.Errorf("validate: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	return rb, nil | ||||
| } | ||||
|  | ||||
| // Build applies mapping rules and returns: | ||||
| // | ||||
| //	resolvedPath — path with placeholders substituted and query appended | ||||
| //	newMsg       — same concrete type as input, filtered to contain only the body fields | ||||
| //	err          — if mapping/validation failed | ||||
| func (b *RequestBuilder) Build() (resolvedPath string, newMsg proto.Message, err error) { | ||||
| 	tmpl, isCached := getCachedPathTemplate(b.path) | ||||
| 	if !isCached { | ||||
| 		tmpl, err = parsePathTemplate(b.path) | ||||
| 		if err != nil { | ||||
| 			return "", nil, fmt.Errorf("parse path template: %w", err) | ||||
| 		} | ||||
| 		setPathTemplateCache(b.path, tmpl) | ||||
| 	} | ||||
|  | ||||
| 	var usedFieldsPath *usedFields | ||||
| 	resolvedPath, usedFieldsPath, err = resolvePathPlaceholders(tmpl, b.msg) | ||||
| 	if err != nil { | ||||
| 		return "", nil, fmt.Errorf("resolve path placeholders: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	// if all set fields are already used in path, no need to process query/body | ||||
| 	if allFieldsUsed(b.msg, usedFieldsPath) { | ||||
| 		return resolvedPath, initZeroMsg(b.msg), nil | ||||
| 	} | ||||
|  | ||||
| 	switch { | ||||
| 	case b.bodyOption.isWithoutBody(): | ||||
| 		var query url.Values | ||||
| 		query, err = buildQuery(b.msg, usedFieldsPath, "") | ||||
| 		if err != nil { | ||||
| 			return "", nil, fmt.Errorf("build query: %w", err) | ||||
| 		} | ||||
|  | ||||
| 		return resolvedPath + encodeQuery(query), initZeroMsg(b.msg), nil | ||||
|  | ||||
| 	case b.bodyOption.isSingleField(): | ||||
| 		fieldBody := b.bodyOption.String() | ||||
|  | ||||
| 		newMsg, err = buildSingleFieldBody(b.msg, fieldBody) | ||||
| 		if err != nil { | ||||
| 			return "", nil, fmt.Errorf("build single field body: %w", err) | ||||
| 		} | ||||
|  | ||||
| 		var query url.Values | ||||
| 		query, err = buildQuery(b.msg, usedFieldsPath, fieldBody) | ||||
| 		if err != nil { | ||||
| 			return "", nil, fmt.Errorf("build query: %w", err) | ||||
| 		} | ||||
|  | ||||
| 		return resolvedPath + encodeQuery(query), newMsg, nil | ||||
|  | ||||
| 	case b.bodyOption.isFullBody(): | ||||
| 		newMsg, err = buildFullBody(b.msg, usedFieldsPath) | ||||
| 		if err != nil { | ||||
| 			return "", nil, fmt.Errorf("build full body: %w", err) | ||||
| 		} | ||||
|  | ||||
| 		return resolvedPath, newMsg, nil | ||||
|  | ||||
| 	default: | ||||
| 		return "", nil, fmt.Errorf("unsupported body option %s", b.bodyOption.String()) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func (b *RequestBuilder) validate() error { | ||||
| 	if b.path == "" { | ||||
| 		return errors.New("path is empty") | ||||
| 	} | ||||
| 	if err := validateHTTPMethod(b.method); err != nil { | ||||
| 		return fmt.Errorf("validate http method: %w", err) | ||||
| 	} | ||||
| 	if err := validateHTTPMethodAndBody(b.method, b.bodyOption); err != nil { | ||||
| 		return fmt.Errorf("validate http method and body: %w", err) | ||||
| 	} | ||||
| 	if b.msg == nil { | ||||
| 		return errors.New("msg is nil") | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func validateHTTPMethod(method string) error { | ||||
| 	switch strings.ToUpper(method) { | ||||
| 	case http.MethodGet, | ||||
| 		http.MethodHead, | ||||
| 		http.MethodPost, | ||||
| 		http.MethodPut, | ||||
| 		http.MethodPatch, | ||||
| 		http.MethodDelete, | ||||
| 		http.MethodConnect, | ||||
| 		http.MethodOptions, | ||||
| 		http.MethodTrace: | ||||
| 		return nil | ||||
| 	default: | ||||
| 		return errors.New("invalid http method") | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func validateHTTPMethodAndBody(method string, bodyOpt bodyOption) error { | ||||
| 	switch method { | ||||
| 	case http.MethodGet, http.MethodDelete, http.MethodHead, http.MethodOptions: | ||||
| 		if !bodyOpt.isWithoutBody() { | ||||
| 			return fmt.Errorf("%s method must not have a body", method) | ||||
| 		} | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func allFieldsUsed(msg proto.Message, used *usedFields) bool { | ||||
| 	if used.len() == 0 { | ||||
| 		return false | ||||
| 	} | ||||
| 	count := 0 | ||||
| 	msg.ProtoReflect().Range(func(protoreflect.FieldDescriptor, protoreflect.Value) bool { | ||||
| 		count++ | ||||
| 		return true | ||||
| 	}) | ||||
| 	return used.len() == count | ||||
| } | ||||
|  | ||||
| func encodeQuery(query url.Values) string { | ||||
| 	if len(query) == 0 { | ||||
| 		return "" | ||||
| 	} | ||||
| 	enc := query.Encode() | ||||
| 	if enc == "" { | ||||
| 		return "" | ||||
| 	} | ||||
| 	return "?" + enc | ||||
| } | ||||
|  | ||||
| func initZeroMsg(msg proto.Message) proto.Message { | ||||
| 	return msg.ProtoReflect().New().Interface() | ||||
| } | ||||
| @@ -1,149 +0,0 @@ | ||||
| package builder | ||||
|  | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"math/rand" | ||||
| 	"strings" | ||||
| 	"testing" | ||||
| 	"time" | ||||
|  | ||||
| 	"google.golang.org/protobuf/proto" | ||||
|  | ||||
| 	pb "go.unistack.org/micro-client-http/v3/builder/proto" | ||||
| ) | ||||
|  | ||||
| // sink prevents the compiler from optimizing away parsePathTemplate results. | ||||
| var sink *pathTemplate | ||||
|  | ||||
| func BenchmarkParsePathTemplate(b *testing.B) { | ||||
| 	r := rand.New(rand.NewSource(1)) | ||||
|  | ||||
| 	benchInput := func(size int) string { | ||||
| 		sb := strings.Builder{} | ||||
| 		sb.Grow(size * 10) | ||||
|  | ||||
| 		for i := 0; i < size; i++ { | ||||
| 			name := fmt.Sprintf("var%d", r.Intn(1000)) | ||||
|  | ||||
| 			if r.Intn(5) == 0 { | ||||
| 				sb.WriteString(fmt.Sprintf("{%s=**}", name)) | ||||
| 			} else { | ||||
| 				sb.WriteString(fmt.Sprintf("{%s}", name)) | ||||
| 			} | ||||
| 		} | ||||
| 		return sb.String() | ||||
| 	} | ||||
|  | ||||
| 	sizes := []int{1_000, 10_000, 50_000, 100_000} | ||||
| 	for _, size := range sizes { | ||||
| 		input := benchInput(size) | ||||
| 		b.Run(fmt.Sprintf("N=%d", size), func(b *testing.B) { | ||||
| 			b.ReportAllocs() | ||||
| 			for i := 0; i < b.N; i++ { | ||||
| 				var err error | ||||
| 				sink, err = parsePathTemplate(input) | ||||
| 				if err != nil && testing.Verbose() { | ||||
| 					b.Fatal(err) | ||||
| 				} | ||||
| 			} | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func BenchmarkRequestBuilder(b *testing.B) { | ||||
| 	r := rand.New(rand.NewSource(time.Now().UnixNano())) | ||||
|  | ||||
| 	makeMsg := func(fieldCount int) proto.Message { | ||||
| 		switch fieldCount { | ||||
| 		case 5: | ||||
| 			return &pb.Benchmark_Case5{ | ||||
| 				Field1: fmt.Sprintf("value%d", r.Intn(1000)), | ||||
| 				Field2: fmt.Sprintf("value%d", r.Intn(1000)), | ||||
| 				Field3: fmt.Sprintf("value%d", r.Intn(1000)), | ||||
| 				Field4: fmt.Sprintf("value%d", r.Intn(1000)), | ||||
| 				Field5: fmt.Sprintf("value%d", r.Intn(1000)), | ||||
| 			} | ||||
| 		case 10: | ||||
| 			return &pb.Benchmark_Case10{ | ||||
| 				Field1:  fmt.Sprintf("value%d", r.Intn(1000)), | ||||
| 				Field2:  fmt.Sprintf("value%d", r.Intn(1000)), | ||||
| 				Field3:  fmt.Sprintf("value%d", r.Intn(1000)), | ||||
| 				Field4:  fmt.Sprintf("value%d", r.Intn(1000)), | ||||
| 				Field5:  fmt.Sprintf("value%d", r.Intn(1000)), | ||||
| 				Field6:  fmt.Sprintf("value%d", r.Intn(1000)), | ||||
| 				Field7:  fmt.Sprintf("value%d", r.Intn(1000)), | ||||
| 				Field8:  fmt.Sprintf("value%d", r.Intn(1000)), | ||||
| 				Field9:  fmt.Sprintf("value%d", r.Intn(1000)), | ||||
| 				Field10: fmt.Sprintf("value%d", r.Intn(1000)), | ||||
| 			} | ||||
| 		case 30: | ||||
| 			return &pb.Benchmark_Case30{ | ||||
| 				Field1:  fmt.Sprintf("value%d", r.Intn(1000)), | ||||
| 				Field2:  fmt.Sprintf("value%d", r.Intn(1000)), | ||||
| 				Field3:  fmt.Sprintf("value%d", r.Intn(1000)), | ||||
| 				Field4:  fmt.Sprintf("value%d", r.Intn(1000)), | ||||
| 				Field5:  fmt.Sprintf("value%d", r.Intn(1000)), | ||||
| 				Field6:  fmt.Sprintf("value%d", r.Intn(1000)), | ||||
| 				Field7:  fmt.Sprintf("value%d", r.Intn(1000)), | ||||
| 				Field8:  fmt.Sprintf("value%d", r.Intn(1000)), | ||||
| 				Field9:  fmt.Sprintf("value%d", r.Intn(1000)), | ||||
| 				Field10: fmt.Sprintf("value%d", r.Intn(1000)), | ||||
| 				Field11: fmt.Sprintf("value%d", r.Intn(1000)), | ||||
| 				Field12: fmt.Sprintf("value%d", r.Intn(1000)), | ||||
| 				Field13: fmt.Sprintf("value%d", r.Intn(1000)), | ||||
| 				Field14: fmt.Sprintf("value%d", r.Intn(1000)), | ||||
| 				Field15: fmt.Sprintf("value%d", r.Intn(1000)), | ||||
| 				Field16: fmt.Sprintf("value%d", r.Intn(1000)), | ||||
| 				Field17: fmt.Sprintf("value%d", r.Intn(1000)), | ||||
| 				Field18: fmt.Sprintf("value%d", r.Intn(1000)), | ||||
| 				Field19: fmt.Sprintf("value%d", r.Intn(1000)), | ||||
| 				Field20: fmt.Sprintf("value%d", r.Intn(1000)), | ||||
| 				Field21: fmt.Sprintf("value%d", r.Intn(1000)), | ||||
| 				Field22: fmt.Sprintf("value%d", r.Intn(1000)), | ||||
| 				Field23: fmt.Sprintf("value%d", r.Intn(1000)), | ||||
| 				Field24: fmt.Sprintf("value%d", r.Intn(1000)), | ||||
| 				Field25: fmt.Sprintf("value%d", r.Intn(1000)), | ||||
| 				Field26: fmt.Sprintf("value%d", r.Intn(1000)), | ||||
| 				Field27: fmt.Sprintf("value%d", r.Intn(1000)), | ||||
| 				Field28: fmt.Sprintf("value%d", r.Intn(1000)), | ||||
| 				Field29: fmt.Sprintf("value%d", r.Intn(1000)), | ||||
| 				Field30: fmt.Sprintf("value%d", r.Intn(1000)), | ||||
| 			} | ||||
| 		default: | ||||
| 			b.Fatal("undefined field count") | ||||
| 			return nil | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	tests := []struct { | ||||
| 		name       string | ||||
| 		pathTmpl   string | ||||
| 		bodyOption string | ||||
| 	}{ | ||||
| 		{"all fields in path", "/resource/{field1}/{field2}", ""}, | ||||
| 		{"single field body", "/resource/{field1}", "field4"}, | ||||
| 		{"full body", "/resource", "*"}, | ||||
| 	} | ||||
|  | ||||
| 	for _, fields := range []int{5, 10, 30} { | ||||
| 		for _, tt := range tests { | ||||
| 			b.Run(fmt.Sprintf("%s_%d_fields", tt.name, fields), func(b *testing.B) { | ||||
| 				b.ReportAllocs() | ||||
| 				b.ResetTimer() | ||||
|  | ||||
| 				for i := 0; i < b.N; i++ { | ||||
| 					msg := makeMsg(fields) | ||||
| 					rb, err := NewRequestBuilder(tt.pathTmpl, "POST", tt.bodyOption, msg) | ||||
| 					if err != nil { | ||||
| 						b.Fatalf("new request builder: %v", err) | ||||
| 					} | ||||
|  | ||||
| 					_, _, err = rb.Build() | ||||
| 					if err != nil { | ||||
| 						b.Fatalf("build: %v", err) | ||||
| 					} | ||||
| 				} | ||||
| 			}) | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,44 +0,0 @@ | ||||
| package builder | ||||
|  | ||||
| import "strings" | ||||
|  | ||||
| // usedFields stores keys and their top-level parts, | ||||
| // turning top-level lookups from O(N) into O(1). | ||||
| type usedFields struct { | ||||
| 	full map[string]struct{} | ||||
| 	top  map[string]struct{} | ||||
| } | ||||
|  | ||||
| func newUsedFields() *usedFields { | ||||
| 	return &usedFields{ | ||||
| 		full: make(map[string]struct{}), | ||||
| 		top:  make(map[string]struct{}), | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // add inserts a new key and updates the top-level index. | ||||
| func (u *usedFields) add(key string) { | ||||
| 	u.full[key] = struct{}{} | ||||
| 	top := key | ||||
| 	if i := strings.IndexByte(key, '.'); i != -1 { | ||||
| 		top = key[:i] | ||||
| 	} | ||||
| 	u.top[top] = struct{}{} | ||||
| } | ||||
|  | ||||
| // hasTopLevelKey checks if a top-level key exists. | ||||
| func (u *usedFields) hasTopLevelKey(top string) bool { | ||||
| 	_, ok := u.top[top] | ||||
| 	return ok | ||||
| } | ||||
|  | ||||
| // hasFullKey checks if an exact key exists. | ||||
| func (u *usedFields) hasFullKey(key string) bool { | ||||
| 	_, ok := u.full[key] | ||||
| 	return ok | ||||
| } | ||||
|  | ||||
| // len returns the number of full keys stored in the set. | ||||
| func (u *usedFields) len() int { | ||||
| 	return len(u.full) | ||||
| } | ||||
| @@ -1,78 +0,0 @@ | ||||
| package builder | ||||
|  | ||||
| import ( | ||||
| 	"testing" | ||||
|  | ||||
| 	"github.com/stretchr/testify/require" | ||||
| ) | ||||
|  | ||||
| func TestNewUsedFields(t *testing.T) { | ||||
| 	u := newUsedFields() | ||||
| 	require.NotNil(t, u) | ||||
| 	require.NotNil(t, u.full) | ||||
| 	require.NotNil(t, u.top) | ||||
| 	require.Len(t, u.full, 0) | ||||
| 	require.Len(t, u.top, 0) | ||||
| } | ||||
|  | ||||
| func TestUsedFields_Add(t *testing.T) { | ||||
| 	u := newUsedFields() | ||||
|  | ||||
| 	u.add("user.name") | ||||
| 	u.add("profile") | ||||
|  | ||||
| 	_, ok := u.full["user.name"] | ||||
| 	require.True(t, ok) | ||||
|  | ||||
| 	_, ok = u.full["profile"] | ||||
| 	require.True(t, ok) | ||||
|  | ||||
| 	_, ok = u.top["user"] | ||||
| 	require.True(t, ok) | ||||
|  | ||||
| 	_, ok = u.top["profile"] | ||||
| 	require.True(t, ok) | ||||
|  | ||||
| 	require.Len(t, u.full, 2) | ||||
| 	require.Len(t, u.top, 2) | ||||
| } | ||||
|  | ||||
| func TestUsedFields_HasFullKey(t *testing.T) { | ||||
| 	u := newUsedFields() | ||||
| 	u.add("user.name") | ||||
|  | ||||
| 	require.True(t, u.hasFullKey("user.name")) | ||||
| 	require.False(t, u.hasFullKey("user.email")) | ||||
| } | ||||
|  | ||||
| func TestUsedFields_HasTopLevelKey(t *testing.T) { | ||||
| 	u := newUsedFields() | ||||
| 	u.add("user.name") | ||||
| 	u.add("settings.theme") | ||||
|  | ||||
| 	require.True(t, u.hasTopLevelKey("user")) | ||||
| 	require.True(t, u.hasTopLevelKey("settings")) | ||||
| 	require.False(t, u.hasTopLevelKey("profile")) | ||||
| } | ||||
|  | ||||
| func TestUsedFields_AddDuplicate(t *testing.T) { | ||||
| 	u := newUsedFields() | ||||
| 	u.add("user.name") | ||||
| 	u.add("user.name") | ||||
|  | ||||
| 	require.True(t, u.hasFullKey("user.name")) | ||||
| 	require.True(t, u.hasTopLevelKey("user")) | ||||
| 	require.Len(t, u.full, 1) | ||||
| 	require.Len(t, u.top, 1) | ||||
| } | ||||
|  | ||||
| func TestUsedFields_Len(t *testing.T) { | ||||
| 	u := newUsedFields() | ||||
|  | ||||
| 	u.add("user.name") | ||||
| 	u.add("profile") | ||||
| 	u.add("user.name") | ||||
| 	u.add("profile") | ||||
|  | ||||
| 	require.Equal(t, u.len(), 2) | ||||
| } | ||||
							
								
								
									
										122
									
								
								client.go
									
									
									
									
									
								
							
							
						
						
									
										122
									
								
								client.go
									
									
									
									
									
								
							| @@ -1,122 +0,0 @@ | ||||
| package http | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"net/http" | ||||
| 	"strconv" | ||||
| 	"sync" | ||||
| 	"time" | ||||
|  | ||||
| 	"go.unistack.org/micro/v3/client" | ||||
| 	"go.unistack.org/micro/v3/errors" | ||||
| 	"go.unistack.org/micro/v3/options" | ||||
| 	"go.unistack.org/micro/v3/semconv" | ||||
| 	"go.unistack.org/micro/v3/tracer" | ||||
| ) | ||||
|  | ||||
| var DefaultContentType = "application/json" | ||||
|  | ||||
| type Client struct { | ||||
| 	funcCall   client.FuncCall | ||||
| 	funcStream client.FuncStream | ||||
| 	httpClient *http.Client | ||||
| 	opts       client.Options | ||||
| 	mu         sync.RWMutex | ||||
| } | ||||
|  | ||||
| func NewClient(opts ...client.Option) *Client { | ||||
| 	clientOpts := client.NewOptions(opts...) | ||||
|  | ||||
| 	if len(clientOpts.ContentType) == 0 { | ||||
| 		clientOpts.ContentType = DefaultContentType | ||||
| 	} | ||||
|  | ||||
| 	c := &Client{opts: clientOpts} | ||||
|  | ||||
| 	dialer, ok := httpDialerFromOpts(clientOpts) | ||||
| 	if !ok { | ||||
| 		dialer = defaultHTTPDialer() | ||||
| 	} | ||||
|  | ||||
| 	c.httpClient, ok = httpClientFromOpts(clientOpts) | ||||
| 	if !ok { | ||||
| 		c.httpClient = defaultHTTPClient(dialer, clientOpts.TLSConfig) | ||||
| 	} | ||||
|  | ||||
| 	c.funcCall = c.fnCall | ||||
| 	c.funcStream = c.fnStream | ||||
|  | ||||
| 	return c | ||||
| } | ||||
|  | ||||
| func (c *Client) Name() string { | ||||
| 	return c.opts.Name | ||||
| } | ||||
|  | ||||
| func (c *Client) Init(opts ...client.Option) error { | ||||
| 	for _, o := range opts { | ||||
| 		o(&c.opts) | ||||
| 	} | ||||
|  | ||||
| 	c.opts.Hooks.EachPrev(func(hook options.Hook) { | ||||
| 		switch h := hook.(type) { | ||||
| 		case client.HookCall: | ||||
| 			c.funcCall = h(c.funcCall) | ||||
| 		case client.HookStream: | ||||
| 			c.funcStream = h(c.funcStream) | ||||
| 		} | ||||
| 	}) | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func (c *Client) Options() client.Options { | ||||
| 	return c.opts | ||||
| } | ||||
|  | ||||
| func (c *Client) NewRequest(service, method string, req any, opts ...client.RequestOption) client.Request { | ||||
| 	reqOpts := client.NewRequestOptions(opts...) | ||||
| 	if reqOpts.ContentType == "" { | ||||
| 		reqOpts.ContentType = c.opts.ContentType | ||||
| 	} | ||||
|  | ||||
| 	return &httpRequest{ | ||||
| 		service: service, | ||||
| 		method:  method, | ||||
| 		request: req, | ||||
| 		opts:    reqOpts, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func (c *Client) Call(ctx context.Context, req client.Request, rsp any, opts ...client.CallOption) error { | ||||
| 	ts := time.Now() | ||||
| 	c.opts.Meter.Counter(semconv.ClientRequestInflight, "endpoint", req.Endpoint()).Inc() | ||||
| 	var sp tracer.Span | ||||
| 	ctx, sp = c.opts.Tracer.Start(ctx, req.Endpoint()+" rpc-client", | ||||
| 		tracer.WithSpanKind(tracer.SpanKindClient), | ||||
| 		tracer.WithSpanLabels("endpoint", req.Endpoint()), | ||||
| 	) | ||||
| 	err := c.funcCall(ctx, req, rsp, opts...) | ||||
| 	c.opts.Meter.Counter(semconv.ClientRequestInflight, "endpoint", req.Endpoint()).Dec() | ||||
| 	te := time.Since(ts) | ||||
| 	c.opts.Meter.Summary(semconv.ClientRequestLatencyMicroseconds, "endpoint", req.Endpoint()).Update(te.Seconds()) | ||||
| 	c.opts.Meter.Histogram(semconv.ClientRequestDurationSeconds, "endpoint", req.Endpoint()).Update(te.Seconds()) | ||||
|  | ||||
| 	if me := errors.FromError(err); me == nil { | ||||
| 		sp.Finish() | ||||
| 		c.opts.Meter.Counter(semconv.ClientRequestTotal, "endpoint", req.Endpoint(), "status", "success", "code", strconv.Itoa(int(200))).Inc() | ||||
| 	} else { | ||||
| 		sp.SetStatus(tracer.SpanStatusError, err.Error()) | ||||
| 		c.opts.Meter.Counter(semconv.ClientRequestTotal, "endpoint", req.Endpoint(), "status", "failure", "code", strconv.Itoa(int(me.Code))).Inc() | ||||
| 	} | ||||
|  | ||||
| 	return err | ||||
| } | ||||
|  | ||||
| func (c *Client) Stream(ctx context.Context, req client.Request, opts ...client.CallOption) (client.Stream, error) { | ||||
| 	return c.funcStream(ctx, req, opts...) | ||||
| } | ||||
|  | ||||
| func (c *Client) String() string { | ||||
| 	return "http" | ||||
| } | ||||
| @@ -1,261 +0,0 @@ | ||||
| package http | ||||
|  | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"context" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"net/url" | ||||
| 	"strings" | ||||
| 	"time" | ||||
|  | ||||
| 	"go.unistack.org/micro/v3/client" | ||||
| 	"go.unistack.org/micro/v3/codec" | ||||
| 	"go.unistack.org/micro/v3/logger" | ||||
| 	"go.unistack.org/micro/v3/metadata" | ||||
| 	"google.golang.org/protobuf/proto" | ||||
| 	"google.golang.org/protobuf/reflect/protoreflect" | ||||
|  | ||||
| 	"go.unistack.org/micro-client-http/v3/builder" | ||||
| ) | ||||
|  | ||||
| func buildHTTPRequest( | ||||
| 	ctx context.Context, | ||||
| 	addr string, | ||||
| 	path string, | ||||
| 	ct string, | ||||
| 	cf codec.Codec, | ||||
| 	msg any, | ||||
| 	opts client.CallOptions, | ||||
| 	log logger.Logger, | ||||
| ) ( | ||||
| 	*http.Request, | ||||
| 	error, | ||||
| ) { | ||||
| 	protoMsg, ok := msg.(proto.Message) | ||||
| 	if !ok { | ||||
| 		return nil, errors.New("msg must be a proto message type") | ||||
| 	} | ||||
|  | ||||
| 	var ( | ||||
| 		method  = http.MethodPost | ||||
| 		bodyOpt string | ||||
|  | ||||
| 		parameters = map[string]map[string]string{} | ||||
| 	) | ||||
|  | ||||
| 	if opts.Context != nil { | ||||
| 		if v, ok := methodFromOpts(opts); ok { | ||||
| 			method = v | ||||
| 		} | ||||
| 		if v, ok := pathFromOpts(opts); ok { | ||||
| 			path = v | ||||
| 		} | ||||
| 		if v, ok := bodyFromOpts(opts); ok { | ||||
| 			bodyOpt = v | ||||
| 		} | ||||
| 		if h, ok := headerFromOpts(opts); ok && len(h) > 0 { | ||||
| 			m, ok := parameters["header"] | ||||
| 			if !ok { | ||||
| 				m = make(map[string]string) | ||||
| 				parameters["header"] = m | ||||
| 			} | ||||
| 			for idx := 0; idx+1 < len(h); idx += 2 { | ||||
| 				m[h[idx]] = h[idx+1] | ||||
| 			} | ||||
| 		} | ||||
| 		if c, ok := cookieFromOpts(opts); ok && len(c) > 0 { | ||||
| 			m, ok := parameters["cookie"] | ||||
| 			if !ok { | ||||
| 				m = make(map[string]string) | ||||
| 				parameters["cookie"] = m | ||||
| 			} | ||||
| 			for idx := 0; idx+1 < len(c); idx += 2 { | ||||
| 				m[c[idx]] = c[idx+1] | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	reqBuilder, err := builder.NewRequestBuilder(path, method, bodyOpt, protoMsg) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("new request builder: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	resolvedPath, newMsg, err := reqBuilder.Build() | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("build request: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	resolvedURL := joinURL(addr, resolvedPath) | ||||
|  | ||||
| 	u, err := normalizeURL(resolvedURL) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("normalize url: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	body, err := marshallMsg(cf, newMsg) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("marshal msg: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	var hreq *http.Request | ||||
|  | ||||
| 	if len(body) > 0 { | ||||
| 		hreq, err = http.NewRequestWithContext(ctx, method, u.String(), io.NopCloser(bytes.NewBuffer(body))) | ||||
| 		hreq.ContentLength = int64(len(body)) | ||||
| 	} else { | ||||
| 		hreq, err = http.NewRequestWithContext(ctx, method, u.String(), nil) | ||||
| 	} | ||||
|  | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("new http request: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	setHeadersAndCookies(ctx, hreq, ct, opts) | ||||
| 	if err = validateHeadersAndCookies(hreq, parameters); err != nil { | ||||
| 		return nil, fmt.Errorf("validate headers and cookies: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	if log.V(logger.DebugLevel) { | ||||
| 		log.Debug( | ||||
| 			ctx, | ||||
| 			fmt.Sprintf("request %s to %s with headers %v body %s", method, u.String(), hreq.Header, body), | ||||
| 		) | ||||
| 	} | ||||
|  | ||||
| 	return hreq, nil | ||||
| } | ||||
|  | ||||
| func joinURL(addr, resolvedPath string) string { | ||||
| 	if addr == "" { | ||||
| 		return resolvedPath | ||||
| 	} | ||||
| 	if resolvedPath == "" { | ||||
| 		return addr | ||||
| 	} | ||||
|  | ||||
| 	switch { | ||||
| 	case strings.HasSuffix(addr, "/") && strings.HasPrefix(resolvedPath, "/"): | ||||
| 		return addr + resolvedPath[1:] | ||||
| 	case !strings.HasSuffix(addr, "/") && !strings.HasPrefix(resolvedPath, "/"): | ||||
| 		return addr + "/" + resolvedPath | ||||
| 	default: | ||||
| 		return addr + resolvedPath | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func normalizeURL(raw string) (*url.URL, error) { | ||||
| 	if !strings.Contains(raw, "://") { | ||||
| 		raw = "http://" + raw | ||||
| 	} | ||||
|  | ||||
| 	u, err := url.Parse(raw) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("parse url: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	if u.Scheme != "http" && u.Scheme != "https" { | ||||
| 		return nil, fmt.Errorf("invalid scheme: %q (must be http or https)", u.Scheme) | ||||
| 	} | ||||
|  | ||||
| 	if u.Host == "" { | ||||
| 		return nil, errors.New("missing host in url") | ||||
| 	} | ||||
|  | ||||
| 	return u, nil | ||||
| } | ||||
|  | ||||
| func marshallMsg(cf codec.Codec, msg proto.Message) ([]byte, error) { | ||||
| 	if msg == nil { | ||||
| 		return nil, nil | ||||
| 	} | ||||
| 	isEmpty := true | ||||
| 	msg.ProtoReflect().Range(func(protoreflect.FieldDescriptor, protoreflect.Value) bool { | ||||
| 		isEmpty = false | ||||
| 		return false | ||||
| 	}) | ||||
| 	if isEmpty { | ||||
| 		return nil, nil | ||||
| 	} | ||||
| 	return cf.Marshal(msg) | ||||
| } | ||||
|  | ||||
| func setHeadersAndCookies(ctx context.Context, r *http.Request, ct string, opts client.CallOptions) { | ||||
| 	r.Header = make(http.Header) | ||||
|  | ||||
| 	r.Header.Set(metadata.HeaderContentType, ct) | ||||
| 	r.Header.Set("Content-Length", fmt.Sprintf("%d", r.ContentLength)) | ||||
|  | ||||
| 	if opts.AuthToken != "" { | ||||
| 		r.Header.Set(metadata.HeaderAuthorization, opts.AuthToken) | ||||
| 	} | ||||
|  | ||||
| 	if opts.StreamTimeout > time.Duration(0) { | ||||
| 		r.Header.Set(metadata.HeaderTimeout, fmt.Sprintf("%d", opts.StreamTimeout)) | ||||
| 	} | ||||
| 	if opts.RequestTimeout > time.Duration(0) { | ||||
| 		r.Header.Set(metadata.HeaderTimeout, fmt.Sprintf("%d", opts.RequestTimeout)) | ||||
| 	} | ||||
|  | ||||
| 	if opts.RequestMetadata != nil { | ||||
| 		for k, v := range opts.RequestMetadata { | ||||
| 			if k == "Cookie" { | ||||
| 				applyCookies(r, v) | ||||
| 				continue | ||||
| 			} | ||||
| 			r.Header[k] = append(r.Header[k], v) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	if md, ok := metadata.FromOutgoingContext(ctx); ok { | ||||
| 		for k, v := range md { | ||||
| 			if k == "Cookie" { | ||||
| 				applyCookies(r, v) | ||||
| 				continue | ||||
| 			} | ||||
| 			r.Header[k] = append(r.Header[k], v) | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func applyCookies(r *http.Request, rawCookies string) { | ||||
| 	if len(rawCookies) == 0 { | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	tmp := http.Request{Header: http.Header{}} | ||||
| 	tmp.Header.Set("Cookie", rawCookies) | ||||
|  | ||||
| 	for _, c := range tmp.Cookies() { | ||||
| 		r.AddCookie(c) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func validateHeadersAndCookies(r *http.Request, parameters map[string]map[string]string) error { | ||||
| 	if headers, ok := parameters["header"]; ok { | ||||
| 		for name, required := range headers { | ||||
| 			if required == "true" && r.Header.Get(name) == "" { | ||||
| 				return fmt.Errorf("missing required header: %s", name) | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	if cookies, ok := parameters["cookie"]; ok { | ||||
| 		cookieMap := map[string]string{} | ||||
| 		for _, c := range r.Cookies() { | ||||
| 			cookieMap[c.Name] = c.Value | ||||
| 		} | ||||
|  | ||||
| 		for name, required := range cookies { | ||||
| 			if required == "true" { | ||||
| 				if _, ok := cookieMap[name]; !ok { | ||||
| 					return fmt.Errorf("missing required cookie: %s", name) | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
| @@ -1,401 +0,0 @@ | ||||
| package http | ||||
|  | ||||
| import ( | ||||
| 	"net/http" | ||||
| 	"net/http/httptest" | ||||
| 	"testing" | ||||
|  | ||||
| 	"github.com/stretchr/testify/require" | ||||
| 	jsoncodec "go.unistack.org/micro-codec-json/v3" | ||||
| 	"google.golang.org/protobuf/proto" | ||||
|  | ||||
| 	pb "go.unistack.org/micro-client-http/v3/builder/proto" | ||||
| ) | ||||
|  | ||||
| func TestJoinURL(t *testing.T) { | ||||
| 	tests := []struct { | ||||
| 		name string | ||||
| 		addr string | ||||
| 		path string | ||||
| 		want string | ||||
| 	}{ | ||||
| 		{ | ||||
| 			name: "both without slash", | ||||
| 			addr: "http://example.com", | ||||
| 			path: "api/v1", | ||||
| 			want: "http://example.com/api/v1", | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "addr with slash, path without slash", | ||||
| 			addr: "http://example.com/", | ||||
| 			path: "api/v1", | ||||
| 			want: "http://example.com/api/v1", | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "addr without slash, path with slash", | ||||
| 			addr: "http://example.com", | ||||
| 			path: "/api/v1", | ||||
| 			want: "http://example.com/api/v1", | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "both with slash", | ||||
| 			addr: "http://example.com/", | ||||
| 			path: "/api/v1", | ||||
| 			want: "http://example.com/api/v1", | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "empty addr", | ||||
| 			addr: "", | ||||
| 			path: "/api/v1", | ||||
| 			want: "/api/v1", | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "empty path", | ||||
| 			addr: "http://example.com", | ||||
| 			path: "", | ||||
| 			want: "http://example.com", | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "both empty", | ||||
| 			addr: "", | ||||
| 			path: "", | ||||
| 			want: "", | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	for _, tt := range tests { | ||||
| 		t.Run(tt.name, func(t *testing.T) { | ||||
| 			require.Equal(t, tt.want, joinURL(tt.addr, tt.path)) | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestNormalizeURL(t *testing.T) { | ||||
| 	tests := []struct { | ||||
| 		name    string | ||||
| 		input   string | ||||
| 		want    string | ||||
| 		wantErr bool | ||||
| 	}{ | ||||
| 		{ | ||||
| 			name:    "host with port", | ||||
| 			input:   "localhost:8080", | ||||
| 			want:    "http://localhost:8080", | ||||
| 			wantErr: false, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:    "http with host", | ||||
| 			input:   "http://example.com", | ||||
| 			want:    "http://example.com", | ||||
| 			wantErr: false, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:    "http with no host", | ||||
| 			input:   "http://", | ||||
| 			want:    "", | ||||
| 			wantErr: true, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:    "https with host", | ||||
| 			input:   "https://example.com", | ||||
| 			want:    "https://example.com", | ||||
| 			wantErr: false, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:    "https with no host", | ||||
| 			input:   "https://", | ||||
| 			want:    "", | ||||
| 			wantErr: true, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:    "invalid scheme", | ||||
| 			input:   "ftp://example.com", | ||||
| 			want:    "", | ||||
| 			wantErr: true, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:    "IPv4 without scheme", | ||||
| 			input:   "127.0.0.1:9000", | ||||
| 			want:    "http://127.0.0.1:9000", | ||||
| 			wantErr: false, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:    "IPv4 with scheme", | ||||
| 			input:   "http://127.0.0.1:8080", | ||||
| 			want:    "http://127.0.0.1:8080", | ||||
| 			wantErr: false, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:    "IPv6 without scheme", | ||||
| 			input:   "[::1]:8080", | ||||
| 			want:    "http://[::1]:8080", | ||||
| 			wantErr: false, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:    "IPv6 with scheme", | ||||
| 			input:   "https://[::1]:443", | ||||
| 			want:    "https://[::1]:443", | ||||
| 			wantErr: false, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:    "hostname only", | ||||
| 			input:   "my-service", | ||||
| 			want:    "http://my-service", | ||||
| 			wantErr: false, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:    "hostname with path", | ||||
| 			input:   "service.local/api/v1", | ||||
| 			want:    "http://service.local/api/v1", | ||||
| 			wantErr: false, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:    "hostname with dash and port", | ||||
| 			input:   "api-service.local:8080", | ||||
| 			want:    "http://api-service.local:8080", | ||||
| 			wantErr: false, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:    "just path", | ||||
| 			input:   "/api/v1", | ||||
| 			want:    "", | ||||
| 			wantErr: true, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:    "empty string", | ||||
| 			input:   "", | ||||
| 			want:    "", | ||||
| 			wantErr: true, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:    "http with query params", | ||||
| 			input:   "http://example.com?x=1&y=2", | ||||
| 			want:    "http://example.com?x=1&y=2", | ||||
| 			wantErr: false, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:    "http with fragment", | ||||
| 			input:   "http://example.com/path#section1", | ||||
| 			want:    "http://example.com/path#section1", | ||||
| 			wantErr: false, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	for _, tt := range tests { | ||||
| 		t.Run(tt.name, func(t *testing.T) { | ||||
| 			result, err := normalizeURL(tt.input) | ||||
| 			if tt.wantErr { | ||||
| 				require.Error(t, err) | ||||
| 				require.Nil(t, result) | ||||
| 			} else { | ||||
| 				require.NoError(t, err) | ||||
| 				require.Equal(t, tt.want, result.String()) | ||||
| 			} | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestMarshallMsg(t *testing.T) { | ||||
| 	type request = pb.Test_Client_Call_Request | ||||
|  | ||||
| 	tests := []struct { | ||||
| 		name     string | ||||
| 		msg      proto.Message | ||||
| 		expected string | ||||
| 	}{ | ||||
| 		{ | ||||
| 			name:     "empty", | ||||
| 			msg:      &request{}, | ||||
| 			expected: "", | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:     "nil", | ||||
| 			msg:      nil, | ||||
| 			expected: "", | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:     "valid", | ||||
| 			msg:      &request{UserId: "123", OrderId: 456}, | ||||
| 			expected: `{"userId":"123","orderId":456}`, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	for _, tt := range tests { | ||||
| 		t.Run(tt.name, func(t *testing.T) { | ||||
| 			result, err := marshallMsg(jsoncodec.NewCodec(), tt.msg) | ||||
| 			require.NoError(t, err) | ||||
| 			require.Equal(t, tt.expected, string(result)) | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestApplyCookies(t *testing.T) { | ||||
| 	tests := []struct { | ||||
| 		name       string | ||||
| 		rawCookies string | ||||
| 		want       []*http.Cookie | ||||
| 	}{ | ||||
| 		{ | ||||
| 			name:       "empty", | ||||
| 			rawCookies: "", | ||||
| 			want:       []*http.Cookie{}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:       "single cookie", | ||||
| 			rawCookies: "session=abc123", | ||||
| 			want: []*http.Cookie{ | ||||
| 				{Name: "session", Value: "abc123"}, | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:       "multiple cookies separate items", | ||||
| 			rawCookies: "session=abc123; user=john", | ||||
| 			want: []*http.Cookie{ | ||||
| 				{Name: "session", Value: "abc123"}, | ||||
| 				{Name: "user", Value: "john"}, | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:       "multiple cookies in one item", | ||||
| 			rawCookies: "a=1; b=2", | ||||
| 			want: []*http.Cookie{ | ||||
| 				{Name: "a", Value: "1"}, | ||||
| 				{Name: "b", Value: "2"}, | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:       "mix of combined and separate cookies", | ||||
| 			rawCookies: "a=1; b=2; c=3", | ||||
| 			want: []*http.Cookie{ | ||||
| 				{Name: "a", Value: "1"}, | ||||
| 				{Name: "b", Value: "2"}, | ||||
| 				{Name: "c", Value: "3"}, | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:       "duplicate cookies", | ||||
| 			rawCookies: "session=abc123; session=xyz", | ||||
| 			want: []*http.Cookie{ | ||||
| 				{Name: "session", Value: "abc123"}, | ||||
| 				{Name: "session", Value: "xyz"}, | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:       "cookie with spaces", | ||||
| 			rawCookies: "token=abc 123", | ||||
| 			want: []*http.Cookie{ | ||||
| 				{Name: "token", Value: "abc 123", Quoted: true}, | ||||
| 			}, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	for _, tt := range tests { | ||||
| 		t.Run(tt.name, func(t *testing.T) { | ||||
| 			req := httptest.NewRequest("GET", "/", nil) | ||||
| 			applyCookies(req, tt.rawCookies) | ||||
| 			require.Equal(t, tt.want, req.Cookies()) | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestValidateHeadersAndCookies(t *testing.T) { | ||||
| 	tests := []struct { | ||||
| 		name           string | ||||
| 		prepareRequest func() *http.Request | ||||
| 		parameters     map[string]map[string]string | ||||
| 		wantErr        bool | ||||
| 	}{ | ||||
| 		{ | ||||
| 			name: "all required headers and cookies present", | ||||
| 			prepareRequest: func() *http.Request { | ||||
| 				req := httptest.NewRequest("GET", "/", nil) | ||||
| 				req.Header.Set("My-Header-1", "Header-Value-1") | ||||
| 				req.Header.Set("My-Header-2", "Header-Value-2") | ||||
| 				req.AddCookie(&http.Cookie{Name: "session-1", Value: "abc-1"}) | ||||
| 				req.AddCookie(&http.Cookie{Name: "session-2", Value: "abc-2"}) | ||||
| 				return req | ||||
| 			}, | ||||
| 			parameters: map[string]map[string]string{ | ||||
| 				"header": {"My-Header-1": "true", "My-Header-2": "true"}, | ||||
| 				"cookie": {"session-1": "true", "session-2": "true"}, | ||||
| 			}, | ||||
| 			wantErr: false, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "missing required header", | ||||
| 			prepareRequest: func() *http.Request { | ||||
| 				req := httptest.NewRequest("GET", "/", nil) | ||||
| 				req.Header.Set("My-Header-1", "Header-Value-1") | ||||
| 				req.AddCookie(&http.Cookie{Name: "session-1", Value: "abc-1"}) | ||||
| 				req.AddCookie(&http.Cookie{Name: "session-2", Value: "abc-2"}) | ||||
| 				return req | ||||
| 			}, | ||||
| 			parameters: map[string]map[string]string{ | ||||
| 				"header": {"My-Header-1": "true", "My-Header-2": "true"}, | ||||
| 				"cookie": {"session-1": "true", "session-2": "true"}, | ||||
| 			}, | ||||
| 			wantErr: true, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "missing required cookie", | ||||
| 			prepareRequest: func() *http.Request { | ||||
| 				req := httptest.NewRequest("GET", "/", nil) | ||||
| 				req.Header.Set("My-Header-1", "Header-Value-1") | ||||
| 				req.Header.Set("My-Header-2", "Header-Value-2") | ||||
| 				req.AddCookie(&http.Cookie{Name: "session-1", Value: "abc-1"}) | ||||
| 				return req | ||||
| 			}, | ||||
| 			parameters: map[string]map[string]string{ | ||||
| 				"header": {"My-Header-1": "true", "My-Header-2": "true"}, | ||||
| 				"cookie": {"session-1": "true", "session-2": "true"}, | ||||
| 			}, | ||||
| 			wantErr: true, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "optional header and cookie not provided partially", | ||||
| 			prepareRequest: func() *http.Request { | ||||
| 				req := httptest.NewRequest("GET", "/", nil) | ||||
| 				req.Header.Set("My-Header-1", "Header-Value-1") | ||||
| 				req.AddCookie(&http.Cookie{Name: "session-1", Value: "abc-1"}) | ||||
| 				return req | ||||
| 			}, | ||||
| 			parameters: map[string]map[string]string{ | ||||
| 				"header": {"My-Header-1": "true", "My-Header-2": "false"}, | ||||
| 				"cookie": {"session-1": "true", "session-2": "false"}, | ||||
| 			}, | ||||
| 			wantErr: false, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "optional header and cookie not provided", | ||||
| 			prepareRequest: func() *http.Request { | ||||
| 				req := httptest.NewRequest("GET", "/", nil) | ||||
| 				return req | ||||
| 			}, | ||||
| 			parameters: map[string]map[string]string{ | ||||
| 				"header": {"My-Header-1": "false", "My-Header-2": "false"}, | ||||
| 				"cookie": {"session-1": "false", "session-2": "false"}, | ||||
| 			}, | ||||
| 			wantErr: false, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "no headers or cookies required", | ||||
| 			prepareRequest: func() *http.Request { | ||||
| 				req := httptest.NewRequest("GET", "/", nil) | ||||
| 				return req | ||||
| 			}, | ||||
| 			parameters: map[string]map[string]string{}, | ||||
| 			wantErr:    false, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	for _, tt := range tests { | ||||
| 		t.Run(tt.name, func(t *testing.T) { | ||||
| 			err := validateHeadersAndCookies(tt.prepareRequest(), tt.parameters) | ||||
| 			if tt.wantErr { | ||||
| 				require.Error(t, err) | ||||
| 			} else { | ||||
| 				require.NoError(t, err) | ||||
| 			} | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
| @@ -1,13 +0,0 @@ | ||||
| package http | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
|  | ||||
| 	"go.unistack.org/micro/v3/client" | ||||
| ) | ||||
|  | ||||
| // TODO: Add stream support in the future. | ||||
|  | ||||
| func (c *Client) fnStream(context.Context, client.Request, ...client.CallOption) (client.Stream, error) { | ||||
| 	panic("not implemented") | ||||
| } | ||||
| @@ -1,272 +0,0 @@ | ||||
| package http | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"net" | ||||
| 	"net/http" | ||||
| 	"net/url" | ||||
| 	"strings" | ||||
| 	"time" | ||||
|  | ||||
| 	"go.unistack.org/micro/v3/client" | ||||
| 	"go.unistack.org/micro/v3/codec" | ||||
| 	"go.unistack.org/micro/v3/errors" | ||||
| 	"go.unistack.org/micro/v3/logger" | ||||
| 	"go.unistack.org/micro/v3/metadata" | ||||
| 	"go.unistack.org/micro/v3/selector" | ||||
|  | ||||
| 	"go.unistack.org/micro-client-http/v3/status" | ||||
| ) | ||||
|  | ||||
| func (c *Client) fnCall(ctx context.Context, req client.Request, rsp any, opts ...client.CallOption) error { | ||||
| 	// make a copy of call opts | ||||
| 	callOpts := c.opts.CallOptions | ||||
| 	for _, opt := range opts { | ||||
| 		opt(&callOpts) | ||||
| 	} | ||||
|  | ||||
| 	// check if we already have a deadline | ||||
| 	d, ok := ctx.Deadline() | ||||
| 	if !ok { | ||||
| 		var cancel context.CancelFunc | ||||
| 		// no deadline so we create a new one | ||||
| 		ctx, cancel = context.WithTimeout(ctx, callOpts.RequestTimeout) | ||||
| 		defer cancel() | ||||
| 	} else { | ||||
| 		// got a deadline so no need to setup context, | ||||
| 		// but we need to set the timeout we pass along | ||||
| 		opt := client.WithRequestTimeout(time.Until(d)) | ||||
| 		opt(&callOpts) | ||||
| 	} | ||||
|  | ||||
| 	// should we noop right here? | ||||
| 	select { | ||||
| 	case <-ctx.Done(): | ||||
| 		return errors.New("go.micro.client", fmt.Sprintf("%v", ctx.Err()), 408) | ||||
| 	default: | ||||
| 	} | ||||
|  | ||||
| 	// make copy of call method | ||||
| 	hcall := c.call | ||||
|  | ||||
| 	// use the router passed as a call option, or fallback to the rpc clients router | ||||
| 	if callOpts.Router == nil { | ||||
| 		callOpts.Router = c.opts.Router | ||||
| 	} | ||||
|  | ||||
| 	if callOpts.Selector == nil { | ||||
| 		callOpts.Selector = c.opts.Selector | ||||
| 	} | ||||
|  | ||||
| 	// inject proxy address | ||||
| 	// TODO: don't even bother using Lookup/Select in this case | ||||
| 	if len(c.opts.Proxy) > 0 { | ||||
| 		callOpts.Address = []string{c.opts.Proxy} | ||||
| 	} | ||||
|  | ||||
| 	var next selector.Next | ||||
|  | ||||
| 	call := func(i int) error { | ||||
| 		// call backoff first. Someone may want an initial start delay | ||||
| 		t, err := callOpts.Backoff(ctx, req, i) | ||||
| 		if err != nil { | ||||
| 			return errors.InternalServerError("go.micro.client", "%+v", err) | ||||
| 		} | ||||
|  | ||||
| 		// only sleep if greater than 0 | ||||
| 		if t.Seconds() > 0 { | ||||
| 			time.Sleep(t) | ||||
| 		} | ||||
|  | ||||
| 		if next == nil { | ||||
| 			var routes []string | ||||
| 			// lookup the route to send the reques to | ||||
| 			// TODO apply any filtering here | ||||
| 			routes, err = c.opts.Lookup(ctx, req, callOpts) | ||||
| 			if err != nil { | ||||
| 				return errors.InternalServerError("go.micro.client", "%+v", err) | ||||
| 			} | ||||
|  | ||||
| 			// balance the list of nodes | ||||
| 			next, err = callOpts.Selector.Select(routes) | ||||
| 			if err != nil { | ||||
| 				return errors.InternalServerError("go.micro.client", "%+v", err) | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		node := next() | ||||
|  | ||||
| 		// make the call | ||||
| 		err = hcall(ctx, node, req, rsp, callOpts) | ||||
|  | ||||
| 		// record the result of the call to inform future routing decisions | ||||
| 		if verr := c.opts.Selector.Record(node, err); verr != nil { | ||||
| 			return errors.InternalServerError("go.micro.client", "%+v", verr) | ||||
| 		} | ||||
|  | ||||
| 		// try and transform the error to micro error | ||||
| 		if verr, ok := err.(*errors.Error); ok { | ||||
| 			return verr | ||||
| 		} | ||||
|  | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	ch := make(chan error, callOpts.Retries) | ||||
| 	var gerr error | ||||
|  | ||||
| 	for i := 0; i <= callOpts.Retries; i++ { | ||||
| 		go func() { | ||||
| 			ch <- call(i) | ||||
| 		}() | ||||
|  | ||||
| 		select { | ||||
| 		case <-ctx.Done(): | ||||
| 			return errors.New("go.micro.client", fmt.Sprintf("%v", ctx.Err()), 408) | ||||
| 		case err := <-ch: | ||||
| 			// if the call succeeded lets bail early | ||||
| 			if err == nil { | ||||
| 				return nil | ||||
| 			} | ||||
|  | ||||
| 			retry, rerr := callOpts.Retry(ctx, req, i, err) | ||||
| 			if rerr != nil { | ||||
| 				return rerr | ||||
| 			} | ||||
|  | ||||
| 			if !retry { | ||||
| 				return err | ||||
| 			} | ||||
|  | ||||
| 			gerr = err | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return gerr | ||||
| } | ||||
|  | ||||
| func (c *Client) call(ctx context.Context, addr string, req client.Request, rsp any, opts client.CallOptions) error { | ||||
| 	ct := req.ContentType() | ||||
| 	if len(opts.ContentType) > 0 { | ||||
| 		ct = opts.ContentType | ||||
| 	} | ||||
|  | ||||
| 	cf, err := c.newCodec(ct) | ||||
| 	if err != nil { | ||||
| 		return errors.BadRequest("go.micro.client", "%+v", err) | ||||
| 	} | ||||
|  | ||||
| 	hreq, err := buildHTTPRequest(ctx, addr, req.Endpoint(), ct, cf, req.Body(), opts, c.opts.Logger) | ||||
| 	if err != nil { | ||||
| 		return errors.BadRequest("go.micro.client", "%+v", err) | ||||
| 	} | ||||
|  | ||||
| 	hrsp, err := c.httpClient.Do(hreq) | ||||
| 	if err != nil { | ||||
| 		switch err := err.(type) { | ||||
| 		case *url.Error: | ||||
| 			if err, ok := err.Err.(net.Error); ok && err.Timeout() { | ||||
| 				return errors.Timeout("go.micro.client", "%+v", err) | ||||
| 			} | ||||
| 		case net.Error: | ||||
| 			if err.Timeout() { | ||||
| 				return errors.Timeout("go.micro.client", "%+v", err) | ||||
| 			} | ||||
| 		} | ||||
| 		return errors.InternalServerError("go.micro.client", "%+v", err) | ||||
| 	} | ||||
|  | ||||
| 	defer hrsp.Body.Close() | ||||
|  | ||||
| 	return c.parseRsp(ctx, hrsp, rsp, opts) | ||||
| } | ||||
|  | ||||
| func (c *Client) newCodec(ct string) (codec.Codec, error) { | ||||
| 	c.mu.RLock() | ||||
| 	defer c.mu.RUnlock() | ||||
|  | ||||
| 	if idx := strings.IndexRune(ct, ';'); idx >= 0 { | ||||
| 		ct = ct[:idx] | ||||
| 	} | ||||
|  | ||||
| 	if cf, ok := c.opts.Codecs[ct]; ok { | ||||
| 		return cf, nil | ||||
| 	} | ||||
|  | ||||
| 	return nil, codec.ErrUnknownContentType | ||||
| } | ||||
|  | ||||
| func (c *Client) parseRsp(ctx context.Context, hrsp *http.Response, rsp any, opts client.CallOptions) error { | ||||
| 	log := c.opts.Logger | ||||
|  | ||||
| 	select { | ||||
| 	case <-ctx.Done(): | ||||
| 		return ctx.Err() | ||||
| 	default: | ||||
| 	} | ||||
|  | ||||
| 	var buf []byte | ||||
|  | ||||
| 	if opts.ResponseMetadata != nil { | ||||
| 		for k, v := range hrsp.Header { | ||||
| 			opts.ResponseMetadata.Set(k, strings.Join(v, ",")) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	if hrsp.StatusCode == http.StatusNoContent { | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| 	ct := DefaultContentType | ||||
| 	if htype := hrsp.Header.Get(metadata.HeaderContentType); htype != "" { | ||||
| 		ct = htype | ||||
| 	} | ||||
|  | ||||
| 	if hrsp.Body != nil { | ||||
| 		var err error | ||||
| 		buf, err = io.ReadAll(hrsp.Body) | ||||
| 		if err != nil { | ||||
| 			return errors.InternalServerError("go.micro.client", "read body: %v", err) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	cf, err := c.newCodec(ct) | ||||
| 	if err != nil { | ||||
| 		return errors.InternalServerError("go.micro.client", "unknown content-type %s: %v", ct, err) | ||||
| 	} | ||||
|  | ||||
| 	if log.V(logger.DebugLevel) { | ||||
| 		log.Debug(ctx, fmt.Sprintf("response with headers: %v and body: %s", hrsp.Header, buf)) | ||||
| 	} | ||||
|  | ||||
| 	if hrsp.StatusCode < http.StatusBadRequest { | ||||
| 		if err = cf.Unmarshal(buf, rsp); err != nil { | ||||
| 			return errors.InternalServerError("go.micro.client", "unmarshal response: %v", err) | ||||
| 		} | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| 	s := status.New(hrsp.StatusCode) | ||||
|  | ||||
| 	var mappedErr any | ||||
|  | ||||
| 	errMap, ok := errorMapFromOpts(opts) | ||||
| 	if ok && errMap != nil { | ||||
| 		mappedErr, ok = errMap[fmt.Sprintf("%d", hrsp.StatusCode)] | ||||
| 		if !ok { | ||||
| 			mappedErr, ok = errMap["default"] | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	if !ok || mappedErr == nil { | ||||
| 		return s.Err() | ||||
| 	} | ||||
|  | ||||
| 	if err = cf.Unmarshal(buf, mappedErr); err != nil { | ||||
| 		return errors.InternalServerError("go.micro.client", "unmarshal response: %v", err) | ||||
| 	} | ||||
|  | ||||
| 	return s.WithDetails(mappedErr).Err() | ||||
| } | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										27
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										27
									
								
								go.mod
									
									
									
									
									
								
							| @@ -1,26 +1,5 @@ | ||||
| module go.unistack.org/micro-client-http/v3 | ||||
| module go.unistack.org/micro-client-http/v4 | ||||
|  | ||||
| go 1.24.0 | ||||
| go 1.19 | ||||
|  | ||||
| toolchain go1.24.3 | ||||
|  | ||||
| require ( | ||||
| 	github.com/stretchr/testify v1.11.1 | ||||
| 	go.unistack.org/micro-codec-json/v3 v3.10.3 | ||||
| 	go.unistack.org/micro/v3 v3.11.45 | ||||
| 	google.golang.org/protobuf v1.36.10 | ||||
| ) | ||||
|  | ||||
| require ( | ||||
| 	github.com/ash3in/uuidv8 v1.2.0 // indirect | ||||
| 	github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect | ||||
| 	github.com/google/uuid v1.6.0 // indirect | ||||
| 	github.com/kr/pretty v0.3.1 // indirect | ||||
| 	github.com/matoous/go-nanoid v1.5.1 // indirect | ||||
| 	github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect | ||||
| 	go.unistack.org/micro-proto/v3 v3.4.1 // indirect | ||||
| 	golang.org/x/sys v0.36.0 // indirect | ||||
| 	google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4 // indirect | ||||
| 	google.golang.org/grpc v1.75.1 // indirect | ||||
| 	gopkg.in/yaml.v3 v3.0.1 // indirect | ||||
| ) | ||||
| require go.unistack.org/micro/v4 v4.0.18 | ||||
|   | ||||
							
								
								
									
										56
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										56
									
								
								go.sum
									
									
									
									
									
								
							| @@ -1,50 +1,6 @@ | ||||
| github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= | ||||
| github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= | ||||
| github.com/ash3in/uuidv8 v1.2.0 h1:2oogGdtCPwaVtyvPPGin4TfZLtOGE5F+W++E880G6SI= | ||||
| github.com/ash3in/uuidv8 v1.2.0/go.mod h1:BnU0wJBxnzdEKmVg4xckBkD+VZuecTFTUP3M0dWgyY4= | ||||
| github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= | ||||
| github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= | ||||
| github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | ||||
| github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= | ||||
| github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= | ||||
| github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= | ||||
| github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= | ||||
| github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= | ||||
| github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= | ||||
| github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= | ||||
| github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= | ||||
| github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= | ||||
| github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= | ||||
| github.com/matoous/go-nanoid v1.5.1 h1:aCjdvTyO9LLnTIi0fgdXhOPPvOHjpXN6Ik9DaNjIct4= | ||||
| github.com/matoous/go-nanoid v1.5.1/go.mod h1:zyD2a71IubI24efhpvkJz+ZwfwagzgSO6UNiFsZKN7U= | ||||
| github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= | ||||
| github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= | ||||
| github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= | ||||
| github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= | ||||
| github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= | ||||
| github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= | ||||
| github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= | ||||
| github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= | ||||
| go.unistack.org/micro-codec-json/v3 v3.10.3 h1:FwSBfJswov30Dyqxp1XfQW1EG4h77uTEe/VGflg6XlY= | ||||
| go.unistack.org/micro-codec-json/v3 v3.10.3/go.mod h1:26OK5MizMNKhspGC6PRVwpDIp5w1GmRb0nE5eRWWDxA= | ||||
| go.unistack.org/micro-proto/v3 v3.4.1 h1:UTjLSRz2YZuaHk9iSlVqqsA50JQNAEK2ZFboGqtEa9Q= | ||||
| go.unistack.org/micro-proto/v3 v3.4.1/go.mod h1:okx/cnOhzuCX0ggl/vToatbCupi0O44diiiLLsZ93Zo= | ||||
| go.unistack.org/micro/v3 v3.11.45 h1:fjTLZYWgsVf9FIMZBxOg8ios2/tmyimnjZrsrxEUeXU= | ||||
| go.unistack.org/micro/v3 v3.11.45/go.mod h1:fDQ8Mu9wubaFP0L8hNQlpzHiEnWN0wbOlawN9HYo0N4= | ||||
| golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= | ||||
| golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= | ||||
| golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= | ||||
| golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= | ||||
| golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= | ||||
| golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= | ||||
| google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4 h1:i8QOKZfYg6AbGVZzUAY3LrNWCKF8O6zFisU9Wl9RER4= | ||||
| google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= | ||||
| google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI= | ||||
| google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= | ||||
| google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= | ||||
| google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= | ||||
| gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | ||||
| gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= | ||||
| gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= | ||||
| gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= | ||||
| gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | ||||
| go.unistack.org/micro/v4 v4.0.1 h1:xo1IxbVfgh8i0eY0VeYa3cbb13u5n/Mxnp3FOgWD4Jo= | ||||
| go.unistack.org/micro/v4 v4.0.1/go.mod h1:p/J5UcSJjfHsWGT31uKoghQ5rUQZzQJBAFy+Z4+ZVMs= | ||||
| go.unistack.org/micro/v4 v4.0.6 h1:YFWvTh3VwyOd6NHYTQcf47n2TF5+p/EhpnbuBQX3qhk= | ||||
| go.unistack.org/micro/v4 v4.0.6/go.mod h1:bVEYTlPi0EsdgZZt311bIroDg9ict7ky3C87dSCCAGk= | ||||
| go.unistack.org/micro/v4 v4.0.18 h1:b7WFwem8Nz1xBrRg5FeLnm9CE5gJseHyf9j0BhkiXW0= | ||||
| go.unistack.org/micro/v4 v4.0.18/go.mod h1:5+da5r835gP0WnNZbYUJDCvWpJ9Xc3IEGyp62e8o8R4= | ||||
|   | ||||
							
								
								
									
										672
									
								
								http.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										672
									
								
								http.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,672 @@ | ||||
| // Package http provides a http client | ||||
| package http // import "go.unistack.org/micro-client-http/v4" | ||||
|  | ||||
| import ( | ||||
| 	"bufio" | ||||
| 	"bytes" | ||||
| 	"context" | ||||
| 	"fmt" | ||||
| 	"io/ioutil" | ||||
| 	"net" | ||||
| 	"net/http" | ||||
| 	"net/url" | ||||
| 	"strings" | ||||
| 	"sync" | ||||
| 	"time" | ||||
|  | ||||
| 	"go.unistack.org/micro/v4/client" | ||||
| 	"go.unistack.org/micro/v4/codec" | ||||
| 	"go.unistack.org/micro/v4/errors" | ||||
| 	"go.unistack.org/micro/v4/logger" | ||||
| 	"go.unistack.org/micro/v4/metadata" | ||||
| 	"go.unistack.org/micro/v4/options" | ||||
| 	"go.unistack.org/micro/v4/selector" | ||||
| 	rutil "go.unistack.org/micro/v4/util/reflect" | ||||
| ) | ||||
|  | ||||
| var DefaultContentType = "application/json" | ||||
|  | ||||
| /* | ||||
| func filterLabel(r []router.Route) []router.Route { | ||||
| 	//				selector.FilterLabel("protocol", "http") | ||||
| 	return r | ||||
| } | ||||
| */ | ||||
|  | ||||
| type httpClient struct { | ||||
| 	httpcli *http.Client | ||||
| 	opts    client.Options | ||||
| 	sync.RWMutex | ||||
| 	init bool | ||||
| } | ||||
|  | ||||
| func newRequest(ctx context.Context, log logger.Logger, addr string, req client.Request, ct string, cf codec.Codec, msg interface{}, opts client.CallOptions) (*http.Request, error) { | ||||
| 	var tags []string | ||||
| 	var parameters map[string]map[string]string | ||||
| 	scheme := "http" | ||||
| 	method := http.MethodPost | ||||
| 	body := "*" // as like google api http annotation | ||||
| 	host := addr | ||||
| 	path := req.Endpoint() | ||||
|  | ||||
| 	u, err := url.Parse(addr) | ||||
| 	if err == nil { | ||||
| 		scheme = u.Scheme | ||||
| 		path = u.Path | ||||
| 		host = u.Host | ||||
| 	} else { | ||||
| 		u = &url.URL{Scheme: scheme, Path: path, Host: host} | ||||
| 	} | ||||
|  | ||||
| 	// nolint: nestif | ||||
| 	if opts.Context != nil { | ||||
| 		if m, ok := opts.Context.Value(methodKey{}).(string); ok { | ||||
| 			method = m | ||||
| 		} | ||||
| 		if p, ok := opts.Context.Value(pathKey{}).(string); ok { | ||||
| 			path += p | ||||
| 		} | ||||
| 		if b, ok := opts.Context.Value(bodyKey{}).(string); ok { | ||||
| 			body = b | ||||
| 		} | ||||
| 		if t, ok := opts.Context.Value(structTagsKey{}).([]string); ok && len(t) > 0 { | ||||
| 			tags = t | ||||
| 		} | ||||
| 		if k, ok := opts.Context.Value(headerKey{}).([]string); ok && len(k) > 0 { | ||||
| 			if parameters == nil { | ||||
| 				parameters = make(map[string]map[string]string) | ||||
| 			} | ||||
| 			m, ok := parameters["header"] | ||||
| 			if !ok { | ||||
| 				m = make(map[string]string) | ||||
| 				parameters["header"] = m | ||||
| 			} | ||||
| 			for idx := 0; idx < len(k)/2; idx += 2 { | ||||
| 				m[k[idx]] = k[idx+1] | ||||
| 			} | ||||
| 		} | ||||
| 		if k, ok := opts.Context.Value(cookieKey{}).([]string); ok && len(k) > 0 { | ||||
| 			if parameters == nil { | ||||
| 				parameters = make(map[string]map[string]string) | ||||
| 			} | ||||
| 			m, ok := parameters["cookie"] | ||||
| 			if !ok { | ||||
| 				m = make(map[string]string) | ||||
| 				parameters["cookie"] = m | ||||
| 			} | ||||
| 			for idx := 0; idx < len(k)/2; idx += 2 { | ||||
| 				m[k[idx]] = k[idx+1] | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	if len(tags) == 0 { | ||||
| 		switch ct { | ||||
| 		default: | ||||
| 			tags = append(tags, "json", "protobuf") | ||||
| 		case "text/xml": | ||||
| 			tags = append(tags, "xml") | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	if path == "" { | ||||
| 		path = req.Endpoint() | ||||
| 	} | ||||
|  | ||||
| 	u, err = u.Parse(path) | ||||
| 	if err != nil { | ||||
| 		return nil, errors.BadRequest("go.micro.client", err.Error()) | ||||
| 	} | ||||
|  | ||||
| 	var nmsg interface{} | ||||
| 	if len(u.Query()) > 0 { | ||||
| 		path, nmsg, err = newPathRequest(u.Path+"?"+u.RawQuery, method, body, msg, tags, parameters) | ||||
| 	} else { | ||||
| 		path, nmsg, err = newPathRequest(u.Path, method, body, msg, tags, parameters) | ||||
| 	} | ||||
|  | ||||
| 	if err != nil { | ||||
| 		return nil, errors.BadRequest("go.micro.client", err.Error()) | ||||
| 	} | ||||
|  | ||||
| 	u, err = url.Parse(fmt.Sprintf("%s://%s%s", scheme, host, path)) | ||||
| 	if err != nil { | ||||
| 		return nil, errors.BadRequest("go.micro.client", err.Error()) | ||||
| 	} | ||||
|  | ||||
| 	var cookies []*http.Cookie | ||||
| 	header := make(http.Header) | ||||
| 	if opts.Context != nil { | ||||
| 		if md, ok := opts.Context.Value(metadataKey{}).(metadata.Metadata); ok { | ||||
| 			for k, v := range md { | ||||
| 				header[k] = v | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 	if opts.AuthToken != "" { | ||||
| 		header.Set(metadata.HeaderAuthorization, opts.AuthToken) | ||||
| 	} | ||||
| 	if opts.RequestMetadata != nil { | ||||
| 		for k, v := range opts.RequestMetadata { | ||||
| 			header[k] = v | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	if md, ok := metadata.FromOutgoingContext(ctx); ok { | ||||
| 		for k, v := range md { | ||||
| 			header[k] = v | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// set timeout in nanoseconds | ||||
| 	if opts.StreamTimeout > time.Duration(0) { | ||||
| 		header.Set(metadata.HeaderTimeout, fmt.Sprintf("%d", opts.StreamTimeout)) | ||||
| 	} | ||||
| 	if opts.RequestTimeout > time.Duration(0) { | ||||
| 		header.Set(metadata.HeaderTimeout, fmt.Sprintf("%d", opts.RequestTimeout)) | ||||
| 	} | ||||
|  | ||||
| 	// set the content type for the request | ||||
| 	header.Set(metadata.HeaderContentType, ct) | ||||
| 	var v interface{} | ||||
|  | ||||
| 	for km, vm := range parameters { | ||||
| 		for k, required := range vm { | ||||
| 			v, err = rutil.StructFieldByPath(msg, k) | ||||
| 			if err != nil { | ||||
| 				return nil, errors.BadRequest("go.micro.client", err.Error()) | ||||
| 			} | ||||
| 			if rutil.IsZero(v) { | ||||
| 				if required == "true" { | ||||
| 					return nil, errors.BadRequest("go.micro.client", fmt.Sprintf("required field %s not set", k)) | ||||
| 				} | ||||
| 				continue | ||||
| 			} | ||||
|  | ||||
| 			switch km { | ||||
| 			case "header": | ||||
| 				header.Set(k, fmt.Sprintf("%v", v)) | ||||
| 			case "cookie": | ||||
| 				cookies = append(cookies, &http.Cookie{Name: k, Value: fmt.Sprintf("%v", v)}) | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	b, err := cf.Marshal(nmsg) | ||||
| 	if err != nil { | ||||
| 		return nil, errors.BadRequest("go.micro.client", err.Error()) | ||||
| 	} | ||||
|  | ||||
| 	var hreq *http.Request | ||||
| 	if len(b) > 0 { | ||||
| 		hreq, err = http.NewRequestWithContext(ctx, method, u.String(), ioutil.NopCloser(bytes.NewBuffer(b))) | ||||
| 		hreq.ContentLength = int64(len(b)) | ||||
| 		header.Set("Content-Length", fmt.Sprintf("%d", hreq.ContentLength)) | ||||
| 	} else { | ||||
| 		hreq, err = http.NewRequestWithContext(ctx, method, u.String(), nil) | ||||
| 	} | ||||
|  | ||||
| 	if err != nil { | ||||
| 		return nil, errors.BadRequest("go.micro.client", err.Error()) | ||||
| 	} | ||||
|  | ||||
| 	hreq.Header = header | ||||
| 	for _, cookie := range cookies { | ||||
| 		hreq.AddCookie(cookie) | ||||
| 	} | ||||
|  | ||||
| 	if log.V(logger.DebugLevel) { | ||||
| 		log.Debug(ctx, fmt.Sprintf("request %s to %s with headers %v body %s", method, u.String(), hreq.Header, b)) | ||||
| 	} | ||||
|  | ||||
| 	return hreq, nil | ||||
| } | ||||
|  | ||||
| func (h *httpClient) call(ctx context.Context, addr string, req client.Request, rsp interface{}, opts client.CallOptions) error { | ||||
| 	ct := req.ContentType() | ||||
| 	if len(opts.ContentType) > 0 { | ||||
| 		ct = opts.ContentType | ||||
| 	} | ||||
|  | ||||
| 	cf, err := h.newCodec(ct) | ||||
| 	if err != nil { | ||||
| 		return errors.BadRequest("go.micro.client", err.Error()) | ||||
| 	} | ||||
| 	hreq, err := newRequest(ctx, h.opts.Logger, addr, req, ct, cf, req.Body(), opts) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	// make the request | ||||
| 	hrsp, err := h.httpcli.Do(hreq) | ||||
| 	if err != nil { | ||||
| 		switch err := err.(type) { | ||||
| 		case *url.Error: | ||||
| 			if err, ok := err.Err.(net.Error); ok && err.Timeout() { | ||||
| 				return errors.Timeout("go.micro.client", err.Error()) | ||||
| 			} | ||||
| 		case net.Error: | ||||
| 			if err.Timeout() { | ||||
| 				return errors.Timeout("go.micro.client", err.Error()) | ||||
| 			} | ||||
| 		} | ||||
| 		return errors.InternalServerError("go.micro.client", err.Error()) | ||||
| 	} | ||||
|  | ||||
| 	defer hrsp.Body.Close() | ||||
|  | ||||
| 	return h.parseRsp(ctx, hrsp, rsp, opts) | ||||
| } | ||||
|  | ||||
| func (h *httpClient) stream(ctx context.Context, addr string, req client.Request, opts client.CallOptions) (client.Stream, error) { | ||||
| 	ct := req.ContentType() | ||||
| 	if len(opts.ContentType) > 0 { | ||||
| 		ct = opts.ContentType | ||||
| 	} | ||||
|  | ||||
| 	// get codec | ||||
| 	cf, err := h.newCodec(ct) | ||||
| 	if err != nil { | ||||
| 		return nil, errors.BadRequest("go.micro.client", err.Error()) | ||||
| 	} | ||||
|  | ||||
| 	cc, err := (h.httpcli.Transport).(*http.Transport).DialContext(ctx, "tcp", addr) | ||||
| 	if err != nil { | ||||
| 		return nil, errors.InternalServerError("go.micro.client", fmt.Sprintf("Error dialing: %v", err)) | ||||
| 	} | ||||
|  | ||||
| 	return &httpStream{ | ||||
| 		address: addr, | ||||
| 		logger:  h.opts.Logger, | ||||
| 		context: ctx, | ||||
| 		closed:  make(chan bool), | ||||
| 		opts:    opts, | ||||
| 		conn:    cc, | ||||
| 		ct:      ct, | ||||
| 		cf:      cf, | ||||
| 		reader:  bufio.NewReader(cc), | ||||
| 		request: req, | ||||
| 	}, nil | ||||
| } | ||||
|  | ||||
| func (h *httpClient) newCodec(ct string) (codec.Codec, error) { | ||||
| 	h.RLock() | ||||
| 	defer h.RUnlock() | ||||
|  | ||||
| 	if idx := strings.IndexRune(ct, ';'); idx >= 0 { | ||||
| 		ct = ct[:idx] | ||||
| 	} | ||||
|  | ||||
| 	if c, ok := h.opts.Codecs[ct]; ok { | ||||
| 		return c, nil | ||||
| 	} | ||||
|  | ||||
| 	return nil, codec.ErrUnknownContentType | ||||
| } | ||||
|  | ||||
| func (h *httpClient) Init(opts ...options.Option) error { | ||||
| 	if len(opts) == 0 && h.init { | ||||
| 		return nil | ||||
| 	} | ||||
| 	for _, o := range opts { | ||||
| 		o(&h.opts) | ||||
| 	} | ||||
|  | ||||
| 	if err := h.opts.Tracer.Init(); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	if err := h.opts.Router.Init(); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	if err := h.opts.Logger.Init(); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	if err := h.opts.Meter.Init(); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func (h *httpClient) Options() client.Options { | ||||
| 	return h.opts | ||||
| } | ||||
|  | ||||
| func (h *httpClient) NewRequest(service, method string, req interface{}, opts ...options.Option) client.Request { | ||||
| 	return newHTTPRequest(service, method, req, h.opts.ContentType, opts...) | ||||
| } | ||||
|  | ||||
| func (h *httpClient) Call(ctx context.Context, req client.Request, rsp interface{}, opts ...options.Option) error { | ||||
| 	// make a copy of call opts | ||||
| 	callOpts := h.opts.CallOptions | ||||
| 	for _, opt := range opts { | ||||
| 		opt(&callOpts) | ||||
| 	} | ||||
|  | ||||
| 	// check if we already have a deadline | ||||
| 	d, ok := ctx.Deadline() | ||||
| 	if !ok { | ||||
| 		var cancel context.CancelFunc | ||||
| 		// no deadline so we create a new one | ||||
| 		ctx, cancel = context.WithTimeout(ctx, callOpts.RequestTimeout) | ||||
| 		defer cancel() | ||||
| 	} else { | ||||
| 		// got a deadline so no need to setup context | ||||
| 		// but we need to set the timeout we pass along | ||||
| 		if err := options.Set(&callOpts, time.Until(d), ".RequestTimeout"); err != nil { | ||||
| 			return errors.New("go.micro.client", fmt.Sprintf("%v", err.Error()), 400) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// should we noop right here? | ||||
| 	select { | ||||
| 	case <-ctx.Done(): | ||||
| 		return errors.New("go.micro.client", fmt.Sprintf("%v", ctx.Err()), 408) | ||||
| 	default: | ||||
| 	} | ||||
|  | ||||
| 	// make copy of call method | ||||
| 	hcall := h.call | ||||
|  | ||||
| 	// wrap the call in reverse | ||||
| 	//for i := len(callOpts.CallWrappers); i > 0; i-- { | ||||
| 	//	hcall = callOpts.CallWrappers[i-1](hcall) | ||||
| 	//} | ||||
|  | ||||
| 	// use the router passed as a call option, or fallback to the rpc clients router | ||||
| 	if callOpts.Router == nil { | ||||
| 		callOpts.Router = h.opts.Router | ||||
| 	} | ||||
|  | ||||
| 	if callOpts.Selector == nil { | ||||
| 		callOpts.Selector = h.opts.Selector | ||||
| 	} | ||||
|  | ||||
| 	// inject proxy address | ||||
| 	// TODO: don't even bother using Lookup/Select in this case | ||||
| 	if len(h.opts.Proxy) > 0 { | ||||
| 		callOpts.Address = []string{h.opts.Proxy} | ||||
| 	} | ||||
|  | ||||
| 	var next selector.Next | ||||
|  | ||||
| 	// return errors.New("go.micro.client", "request timeout", 408) | ||||
| 	call := func(i int) error { | ||||
| 		// call backoff first. Someone may want an initial start delay | ||||
| 		t, err := callOpts.Backoff(ctx, req, i) | ||||
| 		if err != nil { | ||||
| 			return errors.InternalServerError("go.micro.client", err.Error()) | ||||
| 		} | ||||
|  | ||||
| 		// only sleep if greater than 0 | ||||
| 		if t.Seconds() > 0 { | ||||
| 			time.Sleep(t) | ||||
| 		} | ||||
|  | ||||
| 		if next == nil { | ||||
| 			var routes []string | ||||
| 			// lookup the route to send the reques to | ||||
| 			// TODO apply any filtering here | ||||
| 			routes, err = h.opts.Lookup(ctx, req, callOpts) | ||||
| 			if err != nil { | ||||
| 				return errors.InternalServerError("go.micro.client", err.Error()) | ||||
| 			} | ||||
|  | ||||
| 			// balance the list of nodes | ||||
| 			next, err = callOpts.Selector.Select(routes) | ||||
| 			if err != nil { | ||||
| 				return err | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		node := next() | ||||
|  | ||||
| 		// make the call | ||||
| 		err = hcall(ctx, node, req, rsp, callOpts) | ||||
| 		// record the result of the call to inform future routing decisions | ||||
| 		if verr := h.opts.Selector.Record(node, err); verr != nil { | ||||
| 			return verr | ||||
| 		} | ||||
|  | ||||
| 		// try and transform the error to a go-micro error | ||||
| 		if verr, ok := err.(*errors.Error); ok { | ||||
| 			return verr | ||||
| 		} | ||||
|  | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	ch := make(chan error, callOpts.Retries) | ||||
| 	var gerr error | ||||
|  | ||||
| 	for i := 0; i <= callOpts.Retries; i++ { | ||||
| 		go func() { | ||||
| 			ch <- call(i) | ||||
| 		}() | ||||
|  | ||||
| 		select { | ||||
| 		case <-ctx.Done(): | ||||
| 			return errors.New("go.micro.client", fmt.Sprintf("%v", ctx.Err()), 408) | ||||
| 		case err := <-ch: | ||||
| 			// if the call succeeded lets bail early | ||||
| 			if err == nil { | ||||
| 				return nil | ||||
| 			} | ||||
|  | ||||
| 			retry, rerr := callOpts.Retry(ctx, req, i, err) | ||||
| 			if rerr != nil { | ||||
| 				return rerr | ||||
| 			} | ||||
|  | ||||
| 			if !retry { | ||||
| 				return err | ||||
| 			} | ||||
|  | ||||
| 			gerr = err | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return gerr | ||||
| } | ||||
|  | ||||
| func (h *httpClient) Stream(ctx context.Context, req client.Request, opts ...options.Option) (client.Stream, error) { | ||||
| 	var err error | ||||
|  | ||||
| 	// make a copy of call opts | ||||
| 	callOpts := h.opts.CallOptions | ||||
| 	for _, o := range opts { | ||||
| 		o(&callOpts) | ||||
| 	} | ||||
|  | ||||
| 	// check if we already have a deadline | ||||
| 	d, ok := ctx.Deadline() | ||||
| 	if !ok && callOpts.StreamTimeout > time.Duration(0) { | ||||
| 		var cancel context.CancelFunc | ||||
| 		// no deadline so we create a new one | ||||
| 		ctx, cancel = context.WithTimeout(ctx, callOpts.StreamTimeout) | ||||
| 		defer cancel() | ||||
| 	} else { | ||||
| 		// got a deadline so no need to setup context | ||||
| 		// but we need to set the timeout we pass along | ||||
| 		if err = options.Set(&callOpts, time.Until(d), ".StreamTimeout"); err != nil { | ||||
| 			return nil, errors.New("go.micro.client", fmt.Sprintf("%v", err.Error()), 400) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// should we noop right here? | ||||
| 	select { | ||||
| 	case <-ctx.Done(): | ||||
| 		return nil, errors.New("go.micro.client", fmt.Sprintf("%v", ctx.Err()), 408) | ||||
| 	default: | ||||
| 	} | ||||
|  | ||||
| 	/* | ||||
| 		// make copy of call method | ||||
| 		hstream := h.stream | ||||
| 		// wrap the call in reverse | ||||
| 		for i := len(callOpts.CallWrappers); i > 0; i-- { | ||||
| 			hstream = callOpts.CallWrappers[i-1](hstream) | ||||
| 		} | ||||
| 	*/ | ||||
|  | ||||
| 	// use the router passed as a call option, or fallback to the rpc clients router | ||||
| 	if callOpts.Router == nil { | ||||
| 		callOpts.Router = h.opts.Router | ||||
| 	} | ||||
|  | ||||
| 	if callOpts.Selector == nil { | ||||
| 		callOpts.Selector = h.opts.Selector | ||||
| 	} | ||||
|  | ||||
| 	// inject proxy address | ||||
| 	// TODO: don't even bother using Lookup/Select in this case | ||||
| 	if len(h.opts.Proxy) > 0 { | ||||
| 		callOpts.Address = []string{h.opts.Proxy} | ||||
| 	} | ||||
|  | ||||
| 	var next selector.Next | ||||
|  | ||||
| 	call := func(i int) (client.Stream, error) { | ||||
| 		// call backoff first. Someone may want an initial start delay | ||||
| 		t, cerr := callOpts.Backoff(ctx, req, i) | ||||
| 		if cerr != nil { | ||||
| 			return nil, errors.InternalServerError("go.micro.client", cerr.Error()) | ||||
| 		} | ||||
|  | ||||
| 		// only sleep if greater than 0 | ||||
| 		if t.Seconds() > 0 { | ||||
| 			time.Sleep(t) | ||||
| 		} | ||||
|  | ||||
| 		if next == nil { | ||||
| 			var routes []string | ||||
| 			// lookup the route to send the reques to | ||||
| 			// TODO apply any filtering here | ||||
| 			routes, err = h.opts.Lookup(ctx, req, callOpts) | ||||
| 			if err != nil { | ||||
| 				return nil, errors.InternalServerError("go.micro.client", err.Error()) | ||||
| 			} | ||||
|  | ||||
| 			// balance the list of nodes | ||||
| 			next, err = callOpts.Selector.Select(routes) | ||||
| 			if err != nil { | ||||
| 				return nil, err | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		node := next() | ||||
|  | ||||
| 		stream, cerr := h.stream(ctx, node, req, callOpts) | ||||
|  | ||||
| 		// record the result of the call to inform future routing decisions | ||||
| 		if verr := h.opts.Selector.Record(node, cerr); verr != nil { | ||||
| 			return nil, verr | ||||
| 		} | ||||
|  | ||||
| 		// try and transform the error to a go-micro error | ||||
| 		if verr, ok := cerr.(*errors.Error); ok { | ||||
| 			return nil, verr | ||||
| 		} | ||||
|  | ||||
| 		return stream, cerr | ||||
| 	} | ||||
|  | ||||
| 	type response struct { | ||||
| 		stream client.Stream | ||||
| 		err    error | ||||
| 	} | ||||
|  | ||||
| 	ch := make(chan response, callOpts.Retries) | ||||
| 	var grr error | ||||
|  | ||||
| 	for i := 0; i <= callOpts.Retries; i++ { | ||||
| 		go func() { | ||||
| 			s, cerr := call(i) | ||||
| 			ch <- response{s, cerr} | ||||
| 		}() | ||||
|  | ||||
| 		select { | ||||
| 		case <-ctx.Done(): | ||||
| 			return nil, errors.New("go.micro.client", fmt.Sprintf("%v", ctx.Err()), 408) | ||||
| 		case rsp := <-ch: | ||||
| 			// if the call succeeded lets bail early | ||||
| 			if rsp.err == nil { | ||||
| 				return rsp.stream, nil | ||||
| 			} | ||||
|  | ||||
| 			retry, rerr := callOpts.Retry(ctx, req, i, err) | ||||
| 			if rerr != nil { | ||||
| 				return nil, rerr | ||||
| 			} | ||||
|  | ||||
| 			if !retry { | ||||
| 				return nil, rsp.err | ||||
| 			} | ||||
|  | ||||
| 			grr = rsp.err | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return nil, grr | ||||
| } | ||||
|  | ||||
| func (h *httpClient) String() string { | ||||
| 	return "http" | ||||
| } | ||||
|  | ||||
| func (h *httpClient) Name() string { | ||||
| 	return h.opts.Name | ||||
| } | ||||
|  | ||||
| func NewClient(opts ...options.Option) client.Client { | ||||
| 	options := client.NewOptions(opts...) | ||||
|  | ||||
| 	if len(options.ContentType) == 0 { | ||||
| 		options.ContentType = DefaultContentType | ||||
| 	} | ||||
|  | ||||
| 	rc := &httpClient{ | ||||
| 		opts: options, | ||||
| 	} | ||||
|  | ||||
| 	var dialer func(context.Context, string) (net.Conn, error) | ||||
| 	if v, ok := options.Context.Value(httpDialerKey{}).(*net.Dialer); ok { | ||||
| 		dialer = func(ctx context.Context, addr string) (net.Conn, error) { | ||||
| 			return v.DialContext(ctx, "tcp", addr) | ||||
| 		} | ||||
| 	} | ||||
| 	if options.ContextDialer != nil { | ||||
| 		dialer = options.ContextDialer | ||||
| 	} | ||||
| 	if dialer == nil { | ||||
| 		dialer = func(ctx context.Context, addr string) (net.Conn, error) { | ||||
| 			return (&net.Dialer{ | ||||
| 				Timeout:   30 * time.Second, | ||||
| 				KeepAlive: 30 * time.Second, | ||||
| 			}).DialContext(ctx, "tcp", addr) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	if httpcli, ok := options.Context.Value(httpClientKey{}).(*http.Client); ok { | ||||
| 		rc.httpcli = httpcli | ||||
| 	} else { | ||||
| 		// TODO customTransport := http.DefaultTransport.(*http.Transport).Clone() | ||||
| 		tr := &http.Transport{ | ||||
| 			Proxy: http.ProxyFromEnvironment, | ||||
| 			DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { | ||||
| 				return dialer(ctx, addr) | ||||
| 			}, | ||||
| 			ForceAttemptHTTP2:     true, | ||||
| 			MaxConnsPerHost:       100, | ||||
| 			MaxIdleConns:          20, | ||||
| 			IdleConnTimeout:       60 * time.Second, | ||||
| 			TLSHandshakeTimeout:   10 * time.Second, | ||||
| 			ExpectContinueTimeout: 1 * time.Second, | ||||
| 			TLSClientConfig:       options.TLSConfig, | ||||
| 		} | ||||
| 		rc.httpcli = &http.Client{Transport: tr} | ||||
| 	} | ||||
| 	c := client.Client(rc) | ||||
|  | ||||
| 	return c | ||||
| } | ||||
							
								
								
									
										81
									
								
								http_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										81
									
								
								http_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,81 @@ | ||||
| package http | ||||
|  | ||||
| import ( | ||||
| 	"net/url" | ||||
| 	"strings" | ||||
| 	"testing" | ||||
| ) | ||||
|  | ||||
| type Request struct { | ||||
| 	Name     string `json:"name"` | ||||
| 	Field1   string `json:"field1"` | ||||
| 	ClientID string | ||||
| 	Field2   string | ||||
| 	Field3   int64 | ||||
| } | ||||
|  | ||||
| func TestPathWithHeader(t *testing.T) { | ||||
| 	req := &Request{Name: "vtolstov", Field1: "field1", ClientID: "1234567890"} | ||||
| 	p, m, err := newPathRequest( | ||||
| 		"/api/v1/test?Name={name}&Field1={field1}", | ||||
| 		"POST", | ||||
| 		"*", | ||||
| 		req, | ||||
| 		nil, | ||||
| 		map[string]map[string]string{"header": {"ClientID": "true"}}, | ||||
| 	) | ||||
| 	if err != nil { | ||||
| 		t.Fatal(err) | ||||
| 	} | ||||
| 	u, err := url.Parse(p) | ||||
| 	if err != nil { | ||||
| 		t.Fatal(err) | ||||
| 	} | ||||
| 	if m != nil { | ||||
| 		t.Fatal("new struct must be nil") | ||||
| 	} | ||||
| 	if u.Query().Get("Name") != "vtolstov" || u.Query().Get("Field1") != "field1" { | ||||
| 		t.Fatalf("invalid values %v", u.Query()) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestPathValues(t *testing.T) { | ||||
| 	req := &Request{Name: "vtolstov", Field1: "field1"} | ||||
| 	p, m, err := newPathRequest("/api/v1/test?Name={name}&Field1={field1}", "POST", "*", req, nil, nil) | ||||
| 	if err != nil { | ||||
| 		t.Fatal(err) | ||||
| 	} | ||||
| 	u, err := url.Parse(p) | ||||
| 	if err != nil { | ||||
| 		t.Fatal(err) | ||||
| 	} | ||||
| 	_ = m | ||||
| 	if u.Query().Get("Name") != "vtolstov" || u.Query().Get("Field1") != "field1" { | ||||
| 		t.Fatalf("invalid values %v", u.Query()) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestValidPath(t *testing.T) { | ||||
| 	req := &Request{Name: "vtolstov", Field1: "field1", Field2: "field2", Field3: 10} | ||||
| 	p, m, err := newPathRequest("/api/v1/{name}/list", "GET", "", req, nil, nil) | ||||
| 	if err != nil { | ||||
| 		t.Fatal(err) | ||||
| 	} | ||||
| 	u, err := url.Parse(p) | ||||
| 	if err != nil { | ||||
| 		t.Fatal(err) | ||||
| 	} | ||||
| 	_ = m | ||||
| 	parts := strings.Split(u.RawQuery, "&") | ||||
| 	if len(parts) != 3 { | ||||
| 		t.Fatalf("invalid path: %v", parts) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestInvalidPath(t *testing.T) { | ||||
| 	req := &Request{Name: "vtolstov", Field1: "field1", Field2: "field2", Field3: 10} | ||||
| 	_, _, err := newPathRequest("/api/v1/{xname}/list", "GET", "", req, nil, nil) | ||||
| 	if err == nil { | ||||
| 		t.Fatal("path param must not be filled") | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										192
									
								
								options.go
									
									
									
									
									
								
							
							
						
						
									
										192
									
								
								options.go
									
									
									
									
									
								
							| @@ -1,148 +1,126 @@ | ||||
| package http | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"crypto/tls" | ||||
| 	"net" | ||||
| 	"net/http" | ||||
| 	"time" | ||||
|  | ||||
| 	"go.unistack.org/micro/v3/client" | ||||
| 	"go.unistack.org/micro/v4/metadata" | ||||
| 	"go.unistack.org/micro/v4/options" | ||||
| ) | ||||
|  | ||||
| // --------------------------------------------- HTTPClient option ----------------------------------------------------- | ||||
| var ( | ||||
| 	// DefaultPoolMaxStreams maximum streams on a connectioin | ||||
| 	// (20) | ||||
| 	DefaultPoolMaxStreams = 20 | ||||
|  | ||||
| 	// DefaultPoolMaxIdle maximum idle conns of a pool | ||||
| 	// (50) | ||||
| 	DefaultPoolMaxIdle = 50 | ||||
|  | ||||
| 	// DefaultMaxRecvMsgSize maximum message that client can receive | ||||
| 	// (4 MB). | ||||
| 	DefaultMaxRecvMsgSize = 1024 * 1024 * 4 | ||||
|  | ||||
| 	// DefaultMaxSendMsgSize maximum message that client can send | ||||
| 	// (4 MB). | ||||
| 	DefaultMaxSendMsgSize = 1024 * 1024 * 4 | ||||
| ) | ||||
|  | ||||
| type poolMaxStreams struct{} | ||||
|  | ||||
| // PoolMaxStreams maximum streams on a connectioin | ||||
| func PoolMaxStreams(n int) options.Option { | ||||
| 	return options.ContextOption(poolMaxStreams{}, n) | ||||
| } | ||||
|  | ||||
| type poolMaxIdle struct{} | ||||
|  | ||||
| // PoolMaxIdle maximum idle conns of a pool | ||||
| func PoolMaxIdle(d int) options.Option { | ||||
| 	return options.ContextOption(poolMaxIdle{}, d) | ||||
| } | ||||
|  | ||||
| type maxRecvMsgSizeKey struct{} | ||||
|  | ||||
| // MaxRecvMsgSize set the maximum size of message that client can receive. | ||||
| func MaxRecvMsgSize(s int) options.Option { | ||||
| 	return options.ContextOption(maxRecvMsgSizeKey{}, s) | ||||
| } | ||||
|  | ||||
| type maxSendMsgSizeKey struct{} | ||||
|  | ||||
| // MaxSendMsgSize set the maximum size of message that client can send. | ||||
| func MaxSendMsgSize(s int) options.Option { | ||||
| 	return options.ContextOption(maxSendMsgSizeKey{}, s) | ||||
| } | ||||
|  | ||||
| type httpClientKey struct{} | ||||
|  | ||||
| func HTTPClient(c *http.Client) client.Option { | ||||
| 	return client.SetOption(httpClientKey{}, c) | ||||
| // nolint: golint | ||||
| // HTTPClient pass http.Client option to client Call | ||||
| func HTTPClient(c *http.Client) options.Option { | ||||
| 	return options.ContextOption(httpClientKey{}, c) | ||||
| } | ||||
|  | ||||
| func httpClientFromOpts(opts client.Options) (*http.Client, bool) { | ||||
| 	httpClient, ok := opts.Context.Value(httpClientKey{}).(*http.Client) | ||||
| 	return httpClient, ok | ||||
| } | ||||
|  | ||||
| func defaultHTTPClient( | ||||
| 	dialer func(ctx context.Context, addr string) (net.Conn, error), | ||||
| 	tlsConfig *tls.Config, | ||||
| ) *http.Client { | ||||
| 	tr := &http.Transport{ | ||||
| 		Proxy: http.ProxyFromEnvironment, | ||||
| 		DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { | ||||
| 			return dialer(ctx, addr) | ||||
| 		}, | ||||
| 		ForceAttemptHTTP2:     true, | ||||
| 		MaxConnsPerHost:       100, | ||||
| 		MaxIdleConns:          20, | ||||
| 		IdleConnTimeout:       60 * time.Second, | ||||
| 		TLSHandshakeTimeout:   10 * time.Second, | ||||
| 		ExpectContinueTimeout: 1 * time.Second, | ||||
| 		TLSClientConfig:       tlsConfig, | ||||
| 	} | ||||
| 	return &http.Client{Transport: tr} | ||||
| } | ||||
|  | ||||
| // --------------------------------------------- HTTPDialer option ----------------------------------------------------- | ||||
| type httpDialerKey struct{} | ||||
|  | ||||
| func HTTPDialer(d *net.Dialer) client.Option { | ||||
| 	return client.SetOption(httpDialerKey{}, d) | ||||
| // nolint: golint | ||||
| // HTTPDialer pass net.Dialer option to client | ||||
| func HTTPDialer(d *net.Dialer) options.Option { | ||||
| 	return options.ContextOption(httpDialerKey{}, d) | ||||
| } | ||||
|  | ||||
| func httpDialerFromOpts(opts client.Options) (dialerFunc func(context.Context, string) (net.Conn, error), ok bool) { | ||||
| 	var d *net.Dialer | ||||
|  | ||||
| 	if d, ok = opts.Context.Value(httpDialerKey{}).(*net.Dialer); ok { | ||||
| 		dialerFunc = func(ctx context.Context, addr string) (net.Conn, error) { | ||||
| 			return d.DialContext(ctx, "tcp", addr) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	if opts.ContextDialer != nil { | ||||
| 		dialerFunc, ok = opts.ContextDialer, true | ||||
| 	} | ||||
|  | ||||
| 	return dialerFunc, ok | ||||
| } | ||||
|  | ||||
| func defaultHTTPDialer() func(ctx context.Context, addr string) (net.Conn, error) { | ||||
| 	return func(ctx context.Context, addr string) (net.Conn, error) { | ||||
| 		d := &net.Dialer{ | ||||
| 			Timeout:   30 * time.Second, | ||||
| 			KeepAlive: 30 * time.Second, | ||||
| 		} | ||||
| 		return d.DialContext(ctx, "tcp", addr) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // ----------------------------------------------- Method option ------------------------------------------------------- | ||||
| type methodKey struct{} | ||||
|  | ||||
| func Method(m string) client.CallOption { | ||||
| 	return client.SetCallOption(methodKey{}, m) | ||||
| // Method pass method option to client Call | ||||
| func Method(m string) options.Option { | ||||
| 	return options.ContextOption(methodKey{}, m) | ||||
| } | ||||
|  | ||||
| func methodFromOpts(opts client.CallOptions) (string, bool) { | ||||
| 	m, ok := opts.Context.Value(methodKey{}).(string) | ||||
| 	return m, ok | ||||
| } | ||||
|  | ||||
| // ------------------------------------------------ Path option -------------------------------------------------------- | ||||
| type pathKey struct{} | ||||
|  | ||||
| func Path(p string) client.CallOption { | ||||
| 	return client.SetCallOption(pathKey{}, p) | ||||
| // Path spcecifies path option to client Call | ||||
| func Path(p string) options.Option { | ||||
| 	return options.ContextOption(pathKey{}, p) | ||||
| } | ||||
|  | ||||
| func pathFromOpts(opts client.CallOptions) (string, bool) { | ||||
| 	p, ok := opts.Context.Value(pathKey{}).(string) | ||||
| 	return p, ok | ||||
| } | ||||
|  | ||||
| // ------------------------------------------------ Body option -------------------------------------------------------- | ||||
| type bodyKey struct{} | ||||
|  | ||||
| func Body(b string) client.CallOption { | ||||
| 	return client.SetCallOption(bodyKey{}, b) | ||||
| // Body specifies body option to client Call | ||||
| func Body(b string) options.Option { | ||||
| 	return options.ContextOption(bodyKey{}, b) | ||||
| } | ||||
|  | ||||
| func bodyFromOpts(opts client.CallOptions) (string, bool) { | ||||
| 	b, ok := opts.Context.Value(bodyKey{}).(string) | ||||
| 	return b, ok | ||||
| } | ||||
|  | ||||
| // ---------------------------------------------- ErrorMap option ------------------------------------------------------ | ||||
| type errorMapKey struct{} | ||||
|  | ||||
| func ErrorMap(m map[string]any) client.CallOption { | ||||
| 	return client.SetCallOption(errorMapKey{}, m) | ||||
| func ErrorMap(m map[string]interface{}) options.Option { | ||||
| 	return options.ContextOption(errorMapKey{}, m) | ||||
| } | ||||
|  | ||||
| func errorMapFromOpts(opts client.CallOptions) (map[string]any, bool) { | ||||
| 	errMap, ok := opts.Context.Value(errorMapKey{}).(map[string]any) | ||||
| 	return errMap, ok | ||||
| type structTagsKey struct{} | ||||
|  | ||||
| // StructTags pass tags slice option to client Call | ||||
| func StructTags(tags []string) options.Option { | ||||
| 	return options.ContextOption(structTagsKey{}, tags) | ||||
| } | ||||
|  | ||||
| type metadataKey struct{} | ||||
|  | ||||
| // Metadata pass metadata to client Call | ||||
| func Metadata(md metadata.Metadata) options.Option { | ||||
| 	return options.ContextOption(metadataKey{}, md) | ||||
| } | ||||
|  | ||||
| // ------------------------------------------------ Cookie option ------------------------------------------------------ | ||||
| type cookieKey struct{} | ||||
|  | ||||
| func Cookie(cookies ...string) client.CallOption { | ||||
| 	return client.SetCallOption(cookieKey{}, cookies) | ||||
| // Cookie pass cookie to client Call | ||||
| func Cookie(cookies ...string) options.Option { | ||||
| 	return options.ContextOption(cookieKey{}, cookies) | ||||
| } | ||||
|  | ||||
| func cookieFromOpts(opts client.CallOptions) ([]string, bool) { | ||||
| 	c, ok := opts.Context.Value(cookieKey{}).([]string) | ||||
| 	return c, ok | ||||
| } | ||||
|  | ||||
| // ------------------------------------------------ Header option ------------------------------------------------------ | ||||
| type headerKey struct{} | ||||
|  | ||||
| func Header(headers ...string) client.CallOption { | ||||
| 	return client.SetCallOption(headerKey{}, headers) | ||||
| } | ||||
|  | ||||
| func headerFromOpts(opts client.CallOptions) ([]string, bool) { | ||||
| 	h, ok := opts.Context.Value(headerKey{}).([]string) | ||||
| 	return h, ok | ||||
| // Header pass cookie to client Call | ||||
| func Header(headers ...string) options.Option { | ||||
| 	return options.ContextOption(headerKey{}, headers) | ||||
| } | ||||
|   | ||||
							
								
								
									
										45
									
								
								request.go
									
									
									
									
									
								
							
							
						
						
									
										45
									
								
								request.go
									
									
									
									
									
								
							| @@ -1,15 +1,36 @@ | ||||
| package http | ||||
|  | ||||
| import ( | ||||
| 	"go.unistack.org/micro/v3/client" | ||||
| 	"go.unistack.org/micro/v3/codec" | ||||
| 	"go.unistack.org/micro/v4/client" | ||||
| 	"go.unistack.org/micro/v4/codec" | ||||
| 	"go.unistack.org/micro/v4/options" | ||||
| ) | ||||
|  | ||||
| type httpRequest struct { | ||||
| 	service string | ||||
| 	method  string | ||||
| 	request any | ||||
| 	opts    client.RequestOptions | ||||
| 	service     string | ||||
| 	method      string | ||||
| 	contentType string | ||||
| 	request     interface{} | ||||
| 	opts        client.RequestOptions | ||||
| } | ||||
|  | ||||
| func newHTTPRequest(service, method string, request interface{}, contentType string, opts ...options.Option) client.Request { | ||||
| 	options := client.NewRequestOptions(opts...) | ||||
| 	if len(options.ContentType) == 0 { | ||||
| 		options.ContentType = contentType | ||||
| 	} | ||||
|  | ||||
| 	return &httpRequest{ | ||||
| 		service:     service, | ||||
| 		method:      method, | ||||
| 		request:     request, | ||||
| 		contentType: options.ContentType, | ||||
| 		opts:        options, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func (h *httpRequest) ContentType() string { | ||||
| 	return h.contentType | ||||
| } | ||||
|  | ||||
| func (h *httpRequest) Service() string { | ||||
| @@ -24,18 +45,14 @@ func (h *httpRequest) Endpoint() string { | ||||
| 	return h.method | ||||
| } | ||||
|  | ||||
| func (h *httpRequest) ContentType() string { | ||||
| 	return h.opts.ContentType | ||||
| } | ||||
|  | ||||
| func (h *httpRequest) Body() any { | ||||
| 	return h.request | ||||
| } | ||||
|  | ||||
| func (h *httpRequest) Codec() codec.Codec { | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func (h *httpRequest) Body() interface{} { | ||||
| 	return h.request | ||||
| } | ||||
|  | ||||
| func (h *httpRequest) Stream() bool { | ||||
| 	return h.opts.Stream | ||||
| } | ||||
|   | ||||
| @@ -1,14 +0,0 @@ | ||||
| package status | ||||
|  | ||||
| // Error is a thin wrapper around Status that implements the error interface. | ||||
| type Error struct { | ||||
| 	s *Status | ||||
| } | ||||
|  | ||||
| func (e *Error) Error() string { | ||||
| 	return e.s.String() | ||||
| } | ||||
|  | ||||
| func (e *Error) HTTPStatus() *Status { | ||||
| 	return e.s | ||||
| } | ||||
| @@ -1,54 +0,0 @@ | ||||
| package status | ||||
|  | ||||
| import ( | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"net/http" | ||||
| ) | ||||
|  | ||||
| // Status represents the outcome of an HTTP request in a style similar to gRPC status. | ||||
| type Status struct { | ||||
| 	code    int    // HTTP status code | ||||
| 	message string // HTTP status text | ||||
| 	details any    // parsed error object | ||||
| } | ||||
|  | ||||
| func New(statusCode int) *Status { | ||||
| 	return &Status{code: statusCode, message: http.StatusText(statusCode)} | ||||
| } | ||||
|  | ||||
| func FromError(err error) (*Status, bool) { | ||||
| 	if err == nil { | ||||
| 		return nil, false | ||||
| 	} | ||||
| 	var e *Error | ||||
| 	if errors.As(err, &e) { | ||||
| 		return e.HTTPStatus(), true | ||||
| 	} | ||||
| 	return nil, false | ||||
| } | ||||
|  | ||||
| func (s *Status) Code() int { | ||||
| 	return s.code | ||||
| } | ||||
|  | ||||
| func (s *Status) Message() string { | ||||
| 	return s.message | ||||
| } | ||||
|  | ||||
| func (s *Status) Details() any { | ||||
| 	return s.details | ||||
| } | ||||
|  | ||||
| func (s *Status) WithDetails(details any) *Status { | ||||
| 	s.details = details | ||||
| 	return s | ||||
| } | ||||
|  | ||||
| func (s *Status) String() string { | ||||
| 	return fmt.Sprintf("http error: code = %d desc = %s", s.Code(), s.Message()) | ||||
| } | ||||
|  | ||||
| func (s *Status) Err() error { | ||||
| 	return &Error{s: s} | ||||
| } | ||||
| @@ -1,97 +0,0 @@ | ||||
| package status_test | ||||
|  | ||||
| import ( | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"net/http" | ||||
| 	"testing" | ||||
|  | ||||
| 	"github.com/stretchr/testify/require" | ||||
|  | ||||
| 	"go.unistack.org/micro-client-http/v3/status" | ||||
| ) | ||||
|  | ||||
| type fakeError struct{ s *status.Status } | ||||
|  | ||||
| func (fe *fakeError) Error() string { | ||||
| 	return fe.s.String() | ||||
| } | ||||
|  | ||||
| func TestNew(t *testing.T) { | ||||
| 	s := status.New(http.StatusNotFound) | ||||
| 	require.Equal(t, http.StatusNotFound, s.Code()) | ||||
| 	require.Equal(t, "Not Found", s.Message()) | ||||
| } | ||||
|  | ||||
| func TestFromError(t *testing.T) { | ||||
| 	tests := []struct { | ||||
| 		name       string | ||||
| 		input      error | ||||
| 		wantStatus *status.Status | ||||
| 		wantOK     bool | ||||
| 	}{ | ||||
| 		{ | ||||
| 			name:       "nil error", | ||||
| 			input:      nil, | ||||
| 			wantStatus: nil, | ||||
| 			wantOK:     false, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:       "simple error", | ||||
| 			input:      errors.New("some error"), | ||||
| 			wantStatus: nil, | ||||
| 			wantOK:     false, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "unexpected type of error", | ||||
| 			input: func() error { | ||||
| 				return &fakeError{s: status.New(http.StatusNotFound)} | ||||
| 			}(), | ||||
| 			wantStatus: nil, | ||||
| 			wantOK:     false, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:       "expected type of error", | ||||
| 			input:      status.New(http.StatusNotFound).Err(), | ||||
| 			wantStatus: status.New(http.StatusNotFound), | ||||
| 			wantOK:     true, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	for _, tt := range tests { | ||||
| 		t.Run(tt.name, func(t *testing.T) { | ||||
| 			result, ok := status.FromError(tt.input) | ||||
| 			require.Equal(t, tt.wantStatus, result) | ||||
| 			require.Equal(t, tt.wantOK, ok) | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestStatus_Code(t *testing.T) { | ||||
| 	s := status.New(http.StatusNotFound) | ||||
| 	require.Equal(t, http.StatusNotFound, s.Code()) | ||||
| } | ||||
|  | ||||
| func TestStatus_Message(t *testing.T) { | ||||
| 	s := status.New(http.StatusNotFound) | ||||
| 	require.Equal(t, "Not Found", s.Message()) | ||||
| } | ||||
|  | ||||
| func TestStatus_WithDetails(t *testing.T) { | ||||
| 	s := status.New(http.StatusNotFound).WithDetails(errors.New("some error")) | ||||
| 	require.Equal(t, errors.New("some error"), s.Details()) | ||||
| } | ||||
|  | ||||
| func TestStatus_String(t *testing.T) { | ||||
| 	s := status.New(http.StatusInternalServerError) | ||||
| 	expected := fmt.Sprintf("http error: code = %d desc = %s", 500, "Internal Server Error") | ||||
| 	require.Equal(t, expected, s.String()) | ||||
| } | ||||
|  | ||||
| func TestStatus_Err(t *testing.T) { | ||||
| 	var e *status.Error | ||||
| 	s := status.New(http.StatusForbidden) | ||||
| 	require.Error(t, s.Err()) | ||||
| 	require.ErrorAs(t, s.Err(), &e) | ||||
| 	require.Equal(t, status.New(http.StatusForbidden), e.HTTPStatus()) | ||||
| } | ||||
							
								
								
									
										175
									
								
								stream.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										175
									
								
								stream.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,175 @@ | ||||
| package http | ||||
|  | ||||
| import ( | ||||
| 	"bufio" | ||||
| 	"context" | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"net" | ||||
| 	"net/http" | ||||
| 	"sync" | ||||
|  | ||||
| 	"go.unistack.org/micro/v4/client" | ||||
| 	"go.unistack.org/micro/v4/codec" | ||||
| 	"go.unistack.org/micro/v4/errors" | ||||
| 	"go.unistack.org/micro/v4/logger" | ||||
| ) | ||||
|  | ||||
| // Implements the streamer interface | ||||
| type httpStream struct { | ||||
| 	err     error | ||||
| 	conn    net.Conn | ||||
| 	cf      codec.Codec | ||||
| 	context context.Context | ||||
| 	logger  logger.Logger | ||||
| 	request client.Request | ||||
| 	closed  chan bool | ||||
| 	reader  *bufio.Reader | ||||
| 	address string | ||||
| 	ct      string | ||||
| 	opts    client.CallOptions | ||||
| 	sync.RWMutex | ||||
| } | ||||
|  | ||||
| var errShutdown = fmt.Errorf("connection is shut down") | ||||
|  | ||||
| func (h *httpStream) isClosed() bool { | ||||
| 	select { | ||||
| 	case <-h.closed: | ||||
| 		return true | ||||
| 	default: | ||||
| 		return false | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func (h *httpStream) Context() context.Context { | ||||
| 	return h.context | ||||
| } | ||||
|  | ||||
| func (h *httpStream) Request() client.Request { | ||||
| 	return h.request | ||||
| } | ||||
|  | ||||
| func (h *httpStream) Response() client.Response { | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func (h *httpStream) SendMsg(msg interface{}) error { | ||||
| 	return h.Send(msg) | ||||
| } | ||||
|  | ||||
| func (h *httpStream) Send(msg interface{}) error { | ||||
| 	h.Lock() | ||||
| 	defer h.Unlock() | ||||
|  | ||||
| 	if h.isClosed() { | ||||
| 		h.err = errShutdown | ||||
| 		return errShutdown | ||||
| 	} | ||||
|  | ||||
| 	hreq, err := newRequest(h.context, h.logger, h.address, h.request, h.ct, h.cf, msg, h.opts) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	return hreq.Write(h.conn) | ||||
| } | ||||
|  | ||||
| func (h *httpStream) RecvMsg(msg interface{}) error { | ||||
| 	return h.Recv(msg) | ||||
| } | ||||
|  | ||||
| func (h *httpStream) Recv(msg interface{}) error { | ||||
| 	h.Lock() | ||||
| 	defer h.Unlock() | ||||
|  | ||||
| 	if h.isClosed() { | ||||
| 		h.err = errShutdown | ||||
| 		return errShutdown | ||||
| 	} | ||||
|  | ||||
| 	hrsp, err := http.ReadResponse(h.reader, new(http.Request)) | ||||
| 	if err != nil { | ||||
| 		return errors.InternalServerError("go.micro.client", err.Error()) | ||||
| 	} | ||||
| 	defer hrsp.Body.Close() | ||||
|  | ||||
| 	return h.parseRsp(h.context, h.logger, hrsp, h.cf, msg, h.opts) | ||||
| } | ||||
|  | ||||
| func (h *httpStream) Error() error { | ||||
| 	h.RLock() | ||||
| 	defer h.RUnlock() | ||||
| 	return h.err | ||||
| } | ||||
|  | ||||
| func (h *httpStream) CloseSend() error { | ||||
| 	return h.Close() | ||||
| } | ||||
|  | ||||
| func (h *httpStream) Close() error { | ||||
| 	select { | ||||
| 	case <-h.closed: | ||||
| 		return nil | ||||
| 	default: | ||||
| 		close(h.closed) | ||||
| 		return h.conn.Close() | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func (h *httpStream) parseRsp(ctx context.Context, log logger.Logger, hrsp *http.Response, cf codec.Codec, rsp interface{}, opts client.CallOptions) error { | ||||
| 	var err error | ||||
| 	var buf []byte | ||||
|  | ||||
| 	// fast path return | ||||
| 	if hrsp.StatusCode == http.StatusNoContent { | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| 	select { | ||||
| 	case <-ctx.Done(): | ||||
| 		err = ctx.Err() | ||||
| 	default: | ||||
| 		if hrsp.Body != nil { | ||||
| 			buf, err = io.ReadAll(hrsp.Body) | ||||
| 			if err != nil { | ||||
| 				if log.V(logger.ErrorLevel) { | ||||
| 					log.Error(ctx, "failed to read body", err) | ||||
| 				} | ||||
| 				return errors.InternalServerError("go.micro.client", string(buf)) | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		if log.V(logger.DebugLevel) { | ||||
| 			log.Debug(ctx, fmt.Sprintf("response %s with %v", buf, hrsp.Header)) | ||||
| 		} | ||||
|  | ||||
| 		if hrsp.StatusCode < 400 { | ||||
| 			if err = cf.Unmarshal(buf, rsp); err != nil { | ||||
| 				return errors.InternalServerError("go.micro.client", err.Error()) | ||||
| 			} | ||||
| 			return nil | ||||
| 		} | ||||
|  | ||||
| 		var rerr interface{} | ||||
| 		errmap, ok := opts.Context.Value(errorMapKey{}).(map[string]interface{}) | ||||
| 		if ok && errmap != nil { | ||||
| 			if rerr, ok = errmap[fmt.Sprintf("%d", hrsp.StatusCode)].(error); !ok { | ||||
| 				rerr, ok = errmap["default"].(error) | ||||
| 			} | ||||
| 		} | ||||
| 		if !ok || rerr == nil { | ||||
| 			return errors.New("go.micro.client", string(buf), int32(hrsp.StatusCode)) | ||||
| 		} | ||||
|  | ||||
| 		if cerr := cf.Unmarshal(buf, rerr); cerr != nil { | ||||
| 			return errors.InternalServerError("go.micro.client", cerr.Error()) | ||||
| 		} | ||||
|  | ||||
| 		if err, ok = rerr.(error); !ok { | ||||
| 			err = &Error{rerr} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return err | ||||
| } | ||||
							
								
								
									
										356
									
								
								util.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										356
									
								
								util.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,356 @@ | ||||
| package http | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"net/url" | ||||
| 	"reflect" | ||||
| 	"strings" | ||||
| 	"sync" | ||||
|  | ||||
| 	"go.unistack.org/micro/v4/client" | ||||
| 	"go.unistack.org/micro/v4/errors" | ||||
| 	"go.unistack.org/micro/v4/logger" | ||||
| 	"go.unistack.org/micro/v4/metadata" | ||||
| 	rutil "go.unistack.org/micro/v4/util/reflect" | ||||
| ) | ||||
|  | ||||
| var ( | ||||
| 	templateCache = make(map[string][]string) | ||||
| 	mu            sync.RWMutex | ||||
| ) | ||||
|  | ||||
| // Error struct holds error | ||||
| type Error struct { | ||||
| 	err interface{} | ||||
| } | ||||
|  | ||||
| // Error func for error interface | ||||
| func (err *Error) Error() string { | ||||
| 	return fmt.Sprintf("%v", err.err) | ||||
| } | ||||
|  | ||||
| func GetError(err error) interface{} { | ||||
| 	if rerr, ok := err.(*Error); ok { | ||||
| 		return rerr.err | ||||
| 	} | ||||
| 	return err | ||||
| } | ||||
|  | ||||
| func newPathRequest(path string, method string, body string, msg interface{}, tags []string, parameters map[string]map[string]string) (string, interface{}, error) { | ||||
| 	// parse via https://github.com/googleapis/googleapis/blob/master/google/api/http.proto definition | ||||
| 	tpl, err := newTemplate(path) | ||||
| 	if err != nil { | ||||
| 		return "", nil, err | ||||
| 	} | ||||
|  | ||||
| 	if len(tpl) > 0 && msg == nil { | ||||
| 		return "", nil, fmt.Errorf("nil message but path params requested: %v", path) | ||||
| 	} | ||||
|  | ||||
| 	fieldsmapskip := make(map[string]struct{}) | ||||
| 	fieldsmap := make(map[string]string, len(tpl)) | ||||
| 	for _, v := range tpl { | ||||
| 		var vs, ve int | ||||
| 		for i := 0; i < len(v); i++ { | ||||
| 			switch v[i] { | ||||
| 			case '{': | ||||
| 				vs = i + 1 | ||||
| 			case '}': | ||||
| 				ve = i | ||||
| 			} | ||||
| 			if ve != 0 { | ||||
| 				fieldsmap[v[vs:ve]] = "" | ||||
| 				vs = 0 | ||||
| 				ve = 0 | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	nmsg, err := rutil.Zero(msg) | ||||
| 	if err != nil { | ||||
| 		return "", nil, err | ||||
| 	} | ||||
|  | ||||
| 	// we cant switch on message and use proto helpers, to avoid dependency to protobuf | ||||
| 	tmsg := reflect.ValueOf(msg) | ||||
| 	if tmsg.Kind() == reflect.Ptr { | ||||
| 		tmsg = tmsg.Elem() | ||||
| 	} | ||||
|  | ||||
| 	tnmsg := reflect.ValueOf(nmsg) | ||||
| 	if tnmsg.Kind() == reflect.Ptr { | ||||
| 		tnmsg = tnmsg.Elem() | ||||
| 	} | ||||
|  | ||||
| 	values := url.Values{} | ||||
| 	// copy cycle | ||||
| 	for i := 0; i < tmsg.NumField(); i++ { | ||||
| 		val := tmsg.Field(i) | ||||
| 		if val.IsZero() { | ||||
| 			continue | ||||
| 		} | ||||
| 		fld := tmsg.Type().Field(i) | ||||
| 		// Skip unexported fields. | ||||
| 		if fld.PkgPath != "" { | ||||
| 			continue | ||||
| 		} | ||||
| 		/* check for empty PkgPath can be replaced with new method IsExported | ||||
| 		if !fld.IsExported() { | ||||
| 			continue | ||||
| 		} | ||||
| 		*/ | ||||
| 		t := &tag{} | ||||
| 		for _, tn := range tags { | ||||
| 			ts, ok := fld.Tag.Lookup(tn) | ||||
| 			if !ok { | ||||
| 				continue | ||||
| 			} | ||||
|  | ||||
| 			tp := strings.Split(ts, ",") | ||||
| 			// special | ||||
| 			switch tn { | ||||
| 			case "protobuf": // special | ||||
| 				for _, p := range tp { | ||||
| 					if idx := strings.Index(p, "name="); idx > 0 { | ||||
| 						t = &tag{key: tn, name: p[idx:]} | ||||
| 					} | ||||
| 				} | ||||
| 			default: | ||||
| 				t = &tag{key: tn, name: tp[0]} | ||||
| 			} | ||||
| 			if t.name != "" { | ||||
| 				break | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		cname := t.name | ||||
| 		if cname == "" { | ||||
| 			cname = fld.Name | ||||
| 			// fallback to lowercase | ||||
| 			t.name = strings.ToLower(fld.Name) | ||||
| 		} | ||||
| 		if _, ok := parameters["header"][cname]; ok { | ||||
| 			continue | ||||
| 		} | ||||
| 		if _, ok := parameters["cookie"][cname]; ok { | ||||
| 			continue | ||||
| 		} | ||||
|  | ||||
| 		if !val.IsValid() || val.IsZero() { | ||||
| 			continue | ||||
| 		} | ||||
|  | ||||
| 		// nolint: gocritic, nestif | ||||
| 		if _, ok := fieldsmap[t.name]; ok { | ||||
| 			switch val.Type().Kind() { | ||||
| 			case reflect.Slice: | ||||
| 				for idx := 0; idx < val.Len(); idx++ { | ||||
| 					values.Add(t.name, getParam(val.Index(idx))) | ||||
| 				} | ||||
| 				fieldsmapskip[t.name] = struct{}{} | ||||
| 			default: | ||||
| 				fieldsmap[t.name] = getParam(val) | ||||
| 			} | ||||
| 		} else if (body == "*" || body == t.name) && method != http.MethodGet { | ||||
| 			if tnmsg.Field(i).CanSet() { | ||||
| 				tnmsg.Field(i).Set(val) | ||||
| 			} | ||||
| 		} else { | ||||
| 			if val.Type().Kind() == reflect.Slice { | ||||
| 				for idx := 0; idx < val.Len(); idx++ { | ||||
| 					values.Add(t.name, getParam(val.Index(idx))) | ||||
| 				} | ||||
| 			} else { | ||||
| 				values.Add(t.name, getParam(val)) | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// check not filled stuff | ||||
| 	for k, v := range fieldsmap { | ||||
| 		_, ok := fieldsmapskip[k] | ||||
| 		if !ok && v == "" { | ||||
| 			return "", nil, fmt.Errorf("path param %s not filled", k) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	var b strings.Builder | ||||
|  | ||||
| 	for _, fld := range tpl { | ||||
| 		_, _ = b.WriteRune('/') | ||||
| 		// nolint: nestif | ||||
| 		var vs, ve, vf int | ||||
| 		var pholder bool | ||||
| 		for i := 0; i < len(fld); i++ { | ||||
| 			switch fld[i] { | ||||
| 			case '{': | ||||
| 				vs = i + 1 | ||||
| 			case '}': | ||||
| 				ve = i | ||||
| 			} | ||||
| 			// nolint: nestif | ||||
| 			if vs > 0 && ve != 0 { | ||||
| 				if vm, ok := fieldsmap[fld[vs:ve]]; ok { | ||||
| 					if vm != "" { | ||||
| 						_, _ = b.WriteString(fld[vf : vs-1]) | ||||
| 						_, _ = b.WriteString(vm) | ||||
| 						vf = ve + 1 | ||||
| 					} | ||||
| 				} else { | ||||
| 					_, _ = b.WriteString(fld) | ||||
| 				} | ||||
| 				vs = 0 | ||||
| 				ve = 0 | ||||
| 				pholder = true | ||||
| 			} | ||||
| 		} | ||||
| 		if !pholder { | ||||
| 			_, _ = b.WriteString(fld) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	if len(values) > 0 { | ||||
| 		_, _ = b.WriteRune('?') | ||||
| 		_, _ = b.WriteString(values.Encode()) | ||||
| 	} | ||||
|  | ||||
| 	if rutil.IsZero(nmsg) { | ||||
| 		return b.String(), nil, nil | ||||
| 	} | ||||
|  | ||||
| 	return b.String(), nmsg, nil | ||||
| } | ||||
|  | ||||
| func newTemplate(path string) ([]string, error) { | ||||
| 	if len(path) == 0 || path[0] != '/' { | ||||
| 		return nil, fmt.Errorf("path must starts with /") | ||||
| 	} | ||||
| 	mu.RLock() | ||||
| 	tpl, ok := templateCache[path] | ||||
| 	if ok { | ||||
| 		mu.RUnlock() | ||||
| 		return tpl, nil | ||||
| 	} | ||||
| 	mu.RUnlock() | ||||
|  | ||||
| 	tpl = strings.Split(path[1:], "/") | ||||
| 	mu.Lock() | ||||
| 	templateCache[path] = tpl | ||||
| 	mu.Unlock() | ||||
|  | ||||
| 	return tpl, nil | ||||
| } | ||||
|  | ||||
| func (h *httpClient) parseRsp(ctx context.Context, hrsp *http.Response, rsp interface{}, opts client.CallOptions) error { | ||||
| 	var err error | ||||
| 	var buf []byte | ||||
|  | ||||
| 	// fast path return | ||||
| 	if hrsp.StatusCode == http.StatusNoContent { | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| 	if opts.ResponseMetadata != nil { | ||||
| 		*opts.ResponseMetadata = metadata.New(len(hrsp.Header)) | ||||
| 		for k, v := range hrsp.Header { | ||||
| 			opts.ResponseMetadata.Set(k, strings.Join(v, ",")) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	select { | ||||
| 	case <-ctx.Done(): | ||||
| 		err = ctx.Err() | ||||
| 	default: | ||||
| 		ct := DefaultContentType | ||||
|  | ||||
| 		if htype := hrsp.Header.Get("Content-Type"); htype != "" { | ||||
| 			ct = htype | ||||
| 		} | ||||
|  | ||||
| 		if hrsp.Body != nil { | ||||
| 			buf, err = io.ReadAll(hrsp.Body) | ||||
| 			if err != nil { | ||||
| 				if h.opts.Logger.V(logger.ErrorLevel) { | ||||
| 					h.opts.Logger.Error(ctx, "failed to read body", err) | ||||
| 				} | ||||
| 				return errors.InternalServerError("go.micro.client", string(buf)) | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		cf, cerr := h.newCodec(ct) | ||||
| 		if cerr != nil { | ||||
| 			if h.opts.Logger.V(logger.DebugLevel) { | ||||
| 				h.opts.Logger.Debug(ctx, fmt.Sprintf("response with %v unknown content-type %s %s", hrsp.Header, ct, buf)) | ||||
| 			} | ||||
| 			return errors.InternalServerError("go.micro.client", cerr.Error()) | ||||
| 		} | ||||
|  | ||||
| 		if h.opts.Logger.V(logger.DebugLevel) { | ||||
| 			h.opts.Logger.Debug(ctx, fmt.Sprintf("response %s with %v", buf, hrsp.Header)) | ||||
| 		} | ||||
|  | ||||
| 		// succeseful response | ||||
| 		if hrsp.StatusCode < 400 { | ||||
| 			if err = cf.Unmarshal(buf, rsp); err != nil { | ||||
| 				return errors.InternalServerError("go.micro.client", err.Error()) | ||||
| 			} | ||||
| 			return nil | ||||
| 		} | ||||
|  | ||||
| 		// response with error | ||||
| 		var rerr interface{} | ||||
| 		errmap, ok := opts.Context.Value(errorMapKey{}).(map[string]interface{}) | ||||
| 		if ok && errmap != nil { | ||||
| 			rerr, ok = errmap[fmt.Sprintf("%d", hrsp.StatusCode)] | ||||
| 			if !ok { | ||||
| 				rerr, ok = errmap["default"] | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		if !ok || rerr == nil { | ||||
| 			return errors.New("go.micro.client", string(buf), int32(hrsp.StatusCode)) | ||||
| 		} | ||||
|  | ||||
| 		if cerr := cf.Unmarshal(buf, rerr); cerr != nil { | ||||
| 			return errors.InternalServerError("go.micro.client", cerr.Error()) | ||||
| 		} | ||||
|  | ||||
| 		if err, ok = rerr.(error); !ok { | ||||
| 			err = &Error{rerr} | ||||
| 		} | ||||
|  | ||||
| 	} | ||||
|  | ||||
| 	return err | ||||
| } | ||||
|  | ||||
| type tag struct { | ||||
| 	key  string | ||||
| 	name string | ||||
| } | ||||
|  | ||||
| func getParam(val reflect.Value) string { | ||||
| 	var v string | ||||
| 	switch val.Kind() { | ||||
| 	case reflect.Ptr: | ||||
| 		switch reflect.Indirect(val).Type().String() { | ||||
| 		case | ||||
| 			"wrapperspb.BoolValue", | ||||
| 			"wrapperspb.BytesValue", | ||||
| 			"wrapperspb.DoubleValue", | ||||
| 			"wrapperspb.FloatValue", | ||||
| 			"wrapperspb.Int32Value", "wrapperspb.Int64Value", | ||||
| 			"wrapperspb.StringValue", | ||||
| 			"wrapperspb.UInt32Value", "wrapperspb.UInt64Value": | ||||
| 			if eva := reflect.Indirect(val).FieldByName("Value"); eva.IsValid() { | ||||
| 				v = getParam(eva) | ||||
| 			} | ||||
| 		} | ||||
| 	default: | ||||
| 		v = fmt.Sprintf("%v", val.Interface()) | ||||
| 	} | ||||
| 	return v | ||||
| } | ||||
							
								
								
									
										99
									
								
								util_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								util_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,99 @@ | ||||
| package http | ||||
|  | ||||
| import ( | ||||
| 	"net/url" | ||||
| 	"testing" | ||||
| ) | ||||
|  | ||||
| func TestParsing(t *testing.T) { | ||||
| 	type Message struct { | ||||
| 		IIN string `protobuf:"bytes,1,opt,name=iin,proto3" json:"iin"` | ||||
| 	} | ||||
|  | ||||
| 	omsg := &Message{IIN: "5555"} | ||||
|  | ||||
| 	for _, m := range []string{"POST"} { | ||||
| 		body := "" | ||||
| 		path, nmsg, err := newPathRequest("/users/iin/{iin}/push-notifications", m, body, omsg, []string{"protobuf", "json"}, nil) | ||||
| 		if err != nil { | ||||
| 			t.Fatal(err) | ||||
| 		} | ||||
| 		u, err := url.Parse(path) | ||||
| 		if err != nil { | ||||
| 			t.Fatal(err) | ||||
| 		} | ||||
| 		_ = nmsg | ||||
| 		if u.Path != "/users/iin/5555/push-notifications" { | ||||
| 			t.Fatalf("newPathRequest invalid path %s", u.Path) | ||||
| 		} | ||||
| 		if nmsg != nil { | ||||
| 			t.Fatalf("new message must be nil: %v\n", nmsg) | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestNewPathRequest(t *testing.T) { | ||||
| 	type Message struct { | ||||
| 		Name string `json:"name"` | ||||
| 		Val1 string `protobuf:"bytes,1,opt,name=val1,proto3" json:"val1"` | ||||
| 		Val3 []string | ||||
| 		Val2 int64 | ||||
| 	} | ||||
|  | ||||
| 	omsg := &Message{Name: "test_name", Val1: "test_val1", Val2: 100, Val3: []string{"slice"}} | ||||
|  | ||||
| 	for _, m := range []string{"POST", "PUT", "PATCH", "GET", "DELETE"} { | ||||
| 		body := "" | ||||
| 		path, nmsg, err := newPathRequest("/v1/test", m, body, omsg, []string{"protobuf", "json"}, nil) | ||||
| 		if err != nil { | ||||
| 			t.Fatal(err) | ||||
| 		} | ||||
| 		u, err := url.Parse(path) | ||||
| 		if err != nil { | ||||
| 			t.Fatal(err) | ||||
| 		} | ||||
| 		vals := u.Query() | ||||
| 		if v, ok := vals["name"]; !ok || v[0] != "test_name" { | ||||
| 			t.Fatalf("invalid path: %v nmsg: %v", path, nmsg) | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestNewPathVarRequest(t *testing.T) { | ||||
| 	type Message struct { | ||||
| 		Name string `json:"name"` | ||||
| 		Val1 string `protobuf:"bytes,1,opt,name=val1,proto3" json:"val1"` | ||||
| 		Val3 []string | ||||
| 		Val2 int64 | ||||
| 	} | ||||
|  | ||||
| 	omsg := &Message{Name: "test_name", Val1: "test_val1", Val2: 100, Val3: []string{"slice"}} | ||||
|  | ||||
| 	for _, m := range []string{"POST", "PUT", "PATCH", "GET", "DELETE"} { | ||||
| 		body := "" | ||||
| 		if m != "GET" { | ||||
| 			body = "*" | ||||
| 		} | ||||
| 		path, nmsg, err := newPathRequest("/v1/test/{val1}", m, body, omsg, []string{"protobuf", "json"}, nil) | ||||
| 		if err != nil { | ||||
| 			t.Fatal(err) | ||||
| 		} | ||||
| 		u, err := url.Parse(path) | ||||
| 		if err != nil { | ||||
| 			t.Fatal(err) | ||||
| 		} | ||||
| 		if m != "GET" { | ||||
| 			if _, ok := nmsg.(*Message); !ok { | ||||
| 				t.Fatalf("invalid nmsg: %#+v\n", nmsg) | ||||
| 			} | ||||
| 			if nmsg.(*Message).Name != "test_name" { | ||||
| 				t.Fatalf("invalid nmsg: %v nmsg: %v", path, nmsg) | ||||
| 			} | ||||
| 		} else { | ||||
| 			vals := u.Query() | ||||
| 			if v, ok := vals["val2"]; !ok || v[0] != "100" { | ||||
| 				t.Fatalf("invalid path: %v nmsg: %v", path, nmsg) | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
		Reference in New Issue
	
	Block a user