├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── question.md └── workflows │ ├── bench-examples.yml │ ├── bench.yml │ ├── cloc.yml │ ├── golangci-lint.yml │ ├── gorelease.yml │ ├── test-examples.yml │ └── test-unit.yml ├── .gitignore ├── .golangci.yml ├── ADVANCED.md ├── LICENSE ├── Makefile ├── README.md ├── UPGRADE.md ├── _examples ├── .gitignore ├── .golangci.yml ├── Makefile ├── advanced-generic-openapi31 │ ├── _testdata │ │ └── openapi.json │ ├── dummy.go │ ├── error_response.go │ ├── file_multi_upload.go │ ├── file_upload.go │ ├── form.go │ ├── form_or_json.go │ ├── form_or_json_test.go │ ├── gzip_pass_through.go │ ├── gzip_pass_through_test.go │ ├── html_response.go │ ├── html_response_test.go │ ├── json_body.go │ ├── json_body_manual.go │ ├── json_body_manual_test.go │ ├── json_body_test.go │ ├── json_body_validation.go │ ├── json_body_validation_test.go │ ├── json_map_body.go │ ├── json_param.go │ ├── json_slice_body.go │ ├── main.go │ ├── no_validation.go │ ├── output_headers.go │ ├── output_headers_test.go │ ├── output_writer.go │ ├── query_object.go │ ├── query_object_test.go │ ├── raw_body.go │ ├── request_response_mapping.go │ ├── request_response_mapping_test.go │ ├── request_text_body.go │ ├── router.go │ ├── router_test.go │ ├── validation.go │ └── validation_test.go ├── advanced │ ├── _testdata │ │ └── openapi.json │ ├── dummy.go │ ├── dynamic_schema.go │ ├── dynamic_schema_test.go │ ├── error_response.go │ ├── file_multi_upload.go │ ├── file_upload.go │ ├── gzip_pass_through.go │ ├── gzip_pass_through_test.go │ ├── json_body.go │ ├── json_body_test.go │ ├── json_body_validation.go │ ├── json_body_validation_test.go │ ├── json_map_body.go │ ├── json_param.go │ ├── json_slice_body.go │ ├── main.go │ ├── no_validation.go │ ├── output_headers.go │ ├── output_headers_test.go │ ├── output_writer.go │ ├── query_object.go │ ├── request_response_mapping.go │ ├── request_response_mapping_test.go │ ├── router.go │ ├── router_test.go │ ├── validation.go │ └── validation_test.go ├── basic │ ├── README.md │ ├── main.go │ └── screen.png ├── generic │ └── main.go ├── gingonic │ └── main.go ├── go.mod ├── go.sum ├── jwtauth │ └── main.go ├── mount │ ├── main.go │ └── main_test.go ├── multi-api │ └── main.go ├── task-api │ ├── .golangci.yml │ ├── Makefile │ ├── dev_test.go │ ├── internal │ │ ├── domain │ │ │ ├── doc.go │ │ │ └── task │ │ │ │ ├── entity.go │ │ │ │ └── service.go │ │ ├── infra │ │ │ ├── doc.go │ │ │ ├── init.go │ │ │ ├── log │ │ │ │ └── usecase.go │ │ │ ├── nethttp │ │ │ │ ├── benchmark_test.go │ │ │ │ ├── doc.go │ │ │ │ ├── integration_test.go │ │ │ │ └── router.go │ │ │ ├── repository │ │ │ │ └── task.go │ │ │ ├── schema │ │ │ │ └── openapi.go │ │ │ └── service │ │ │ │ ├── config.go │ │ │ │ ├── doc.go │ │ │ │ ├── locator.go │ │ │ │ └── provider.go │ │ └── usecase │ │ │ ├── create_task.go │ │ │ ├── doc.go │ │ │ ├── find_task.go │ │ │ ├── find_tasks.go │ │ │ ├── finish_task.go │ │ │ └── update_task.go │ ├── main.go │ └── pkg │ │ └── graceful │ │ ├── doc.go │ │ ├── http.go │ │ └── shutdown.go └── tools_test.go ├── chirouter ├── doc.go ├── example_test.go ├── path_decoder.go ├── wrapper.go └── wrapper_test.go ├── dev_test.go ├── doc.go ├── error.go ├── error_test.go ├── go.mod ├── go.sum ├── gorillamux ├── collector.go ├── collector_test.go ├── doc.go ├── example_openapi_collector_test.go ├── example_test.go └── path_decoder.go ├── gzip ├── container.go ├── container_test.go └── doc.go ├── jsonschema ├── validator.go └── validator_test.go ├── nethttp ├── doc.go ├── example_test.go ├── handler.go ├── handler_test.go ├── openapi.go ├── openapi_test.go ├── options.go ├── options_test.go ├── usecase.go ├── wrap.go ├── wrap_test.go ├── wrapper.go └── wrapper_test.go ├── openapi ├── collector.go └── collector_test.go ├── request.go ├── request ├── decoder.go ├── decoder_test.go ├── doc.go ├── error.go ├── example_test.go ├── factory.go ├── factory_test.go ├── file.go ├── file_test.go ├── jsonbody.go ├── jsonbody_test.go ├── middleware.go └── reflect.go ├── request_test.go ├── response.go ├── response ├── doc.go ├── encoder.go ├── encoder_test.go ├── gzip │ ├── doc.go │ ├── middleware.go │ └── middleware_test.go ├── middleware.go ├── middleware_test.go ├── validator.go └── validator_test.go ├── resttest ├── client.go ├── client_test.go ├── doc.go ├── example_test.go ├── server.go └── server_test.go ├── route.go ├── trait.go ├── trait_test.go ├── validator.go └── web ├── _testdata └── openapi.json ├── example_test.go ├── service.go └── service_test.go /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | If feasible/relevant, please provide a code snippet (inline or with Go playground) to reproduce the issue. 15 | 16 | 17 | **Expected behavior** 18 | A clear and concise description of what you expected to happen. 19 | 20 | **Additional context** 21 | Add any other context about the problem here. 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | Please use discussions https://github.com/swaggest/rest/discussions/categories/ideas to share feature ideas. 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 3 | about: Any question about features or usage 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | Please use discussions https://github.com/swaggest/rest/discussions/categories/q-a to make your question more discoverable by other folks. 11 | -------------------------------------------------------------------------------- /.github/workflows/bench-examples.yml: -------------------------------------------------------------------------------- 1 | name: bench-examples 2 | on: 3 | pull_request: 4 | workflow_dispatch: 5 | inputs: 6 | old: 7 | description: 'Old Ref' 8 | required: false 9 | default: 'master' 10 | new: 11 | description: 'New Ref' 12 | required: true 13 | 14 | env: 15 | GO111MODULE: "on" 16 | CACHE_BENCHMARK: "off" # Enables benchmark result reuse between runs, may skew latency results. 17 | RUN_BASE_BENCHMARK: "on" # Runs benchmark for PR base in case benchmark result is missing. 18 | GO_VERSION: stable 19 | jobs: 20 | bench: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - name: Install Go stable 24 | uses: actions/setup-go@v5 25 | with: 26 | go-version: ${{ env.GO_VERSION }} 27 | - name: Checkout code 28 | uses: actions/checkout@v4 29 | with: 30 | ref: ${{ (github.event.inputs.new != '') && github.event.inputs.new || github.event.ref }} 31 | - name: Go cache 32 | uses: actions/cache@v4 33 | with: 34 | # In order: 35 | # * Module download cache 36 | # * Build cache (Linux) 37 | path: | 38 | ~/go/pkg/mod 39 | ~/.cache/go-build 40 | key: ${{ runner.os }}-go-cache-examples-${{ hashFiles('**/go.sum') }} 41 | restore-keys: | 42 | ${{ runner.os }}-go-cache 43 | - name: Restore benchstat 44 | uses: actions/cache@v4 45 | with: 46 | path: ~/go/bin/benchstat 47 | key: ${{ runner.os }}-benchstat 48 | - name: Restore base benchmark result 49 | if: env.CACHE_BENCHMARK == 'on' 50 | id: benchmark-base 51 | uses: actions/cache@v4 52 | with: 53 | path: | 54 | bench-master.txt 55 | bench-main.txt 56 | # Use base sha for PR or new commit hash for master/main push in benchmark result key. 57 | key: ${{ runner.os }}-bench-${{ (github.event.pull_request.base.sha != github.event.after) && github.event.pull_request.base.sha || github.event.after }} 58 | - name: Checkout base code 59 | if: env.RUN_BASE_BENCHMARK == 'on' && steps.benchmark-base.outputs.cache-hit != 'true' && (github.event.pull_request.base.sha != '' || github.event.inputs.old != '') 60 | uses: actions/checkout@v4 61 | with: 62 | ref: ${{ (github.event.pull_request.base.sha != '' ) && github.event.pull_request.base.sha || github.event.inputs.old }} 63 | path: __base 64 | - name: Run base benchmark 65 | if: env.RUN_BASE_BENCHMARK == 'on' && steps.benchmark-base.outputs.cache-hit != 'true' && (github.event.pull_request.base.sha != '' || github.event.inputs.old != '') 66 | run: | 67 | export REF_NAME=master 68 | cd __base 69 | go mod tidy 70 | BENCH_COUNT=5 make bench-run-examples 71 | ls -lah 72 | pwd 73 | cat bench-examples-master.txt 74 | cp bench-examples-master.txt ../bench-examples-master.txt 75 | - name: Benchmark 76 | id: bench 77 | run: | 78 | export REF_NAME=new 79 | go mod tidy 80 | BENCH_COUNT=5 make bench-run-examples bench-stat-examples 81 | OUTPUT=$(make bench-stat-examples) 82 | echo "${OUTPUT}" 83 | OUTPUT="${OUTPUT//$'\n'/%0A}" 84 | echo "::set-output name=result::$OUTPUT" 85 | - name: Comment Benchmark Examples Result 86 | continue-on-error: true 87 | uses: marocchino/sticky-pull-request-comment@v2 88 | with: 89 | header: bench-examples 90 | message: | 91 | ### Examples Benchmark Result 92 |
Benchmark diff with base branch 93 | 94 | ``` 95 | ${{ steps.bench.outputs.result }} 96 | ``` 97 |
98 | -------------------------------------------------------------------------------- /.github/workflows/cloc.yml: -------------------------------------------------------------------------------- 1 | # This script is provided by github.com/bool64/dev. 2 | name: cloc 3 | on: 4 | pull_request: 5 | 6 | # Cancel the workflow in progress in newer build is about to start. 7 | concurrency: 8 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 9 | cancel-in-progress: true 10 | 11 | jobs: 12 | cloc: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v4 17 | with: 18 | path: pr 19 | - name: Checkout base code 20 | uses: actions/checkout@v4 21 | with: 22 | ref: ${{ github.event.pull_request.base.sha }} 23 | path: base 24 | - name: Count lines of code 25 | id: loc 26 | run: | 27 | curl -sLO https://github.com/vearutop/sccdiff/releases/download/v1.0.3/linux_amd64.tar.gz && tar xf linux_amd64.tar.gz 28 | sccdiff_hash=$(git hash-object ./sccdiff) 29 | [ "$sccdiff_hash" == "ae8a07b687bd3dba60861584efe724351aa7ff63" ] || (echo "::error::unexpected hash for sccdiff, possible tampering: $sccdiff_hash" && exit 1) 30 | OUTPUT=$(cd pr && ../sccdiff -basedir ../base) 31 | echo "${OUTPUT}" 32 | echo "diff<> $GITHUB_OUTPUT && echo "$OUTPUT" >> $GITHUB_OUTPUT && echo "EOF" >> $GITHUB_OUTPUT 33 | 34 | - name: Comment lines of code 35 | continue-on-error: true 36 | uses: marocchino/sticky-pull-request-comment@v2 37 | with: 38 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 39 | header: LOC 40 | message: | 41 | ### Lines Of Code 42 | 43 | ${{ steps.loc.outputs.diff }} 44 | -------------------------------------------------------------------------------- /.github/workflows/golangci-lint.yml: -------------------------------------------------------------------------------- 1 | # This script is provided by github.com/bool64/dev. 2 | name: lint 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | branches: 8 | - master 9 | - main 10 | pull_request: 11 | 12 | # Cancel the workflow in progress in newer build is about to start. 13 | concurrency: 14 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 15 | cancel-in-progress: true 16 | 17 | jobs: 18 | golangci: 19 | name: golangci-lint 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/setup-go@v5 23 | with: 24 | go-version: stable 25 | - uses: actions/checkout@v4 26 | - name: golangci-lint 27 | uses: golangci/golangci-lint-action@v6.5.0 28 | with: 29 | # Required: the version of golangci-lint is required and must be specified without patch version: we always use the latest patch version. 30 | version: v1.64.5 31 | 32 | # Optional: working directory, useful for monorepos 33 | # working-directory: somedir 34 | 35 | # Optional: golangci-lint command line arguments. 36 | # args: --issues-exit-code=0 37 | 38 | # Optional: show only new issues if it's a pull request. The default value is `false`. 39 | # only-new-issues: true 40 | 41 | # Optional: if set to true then the action will use pre-installed Go. 42 | # skip-go-installation: true 43 | 44 | # Optional: if set to true then the action don't cache or restore ~/go/pkg. 45 | # skip-pkg-cache: true 46 | 47 | # Optional: if set to true then the action don't cache or restore ~/.cache/go-build. 48 | # skip-build-cache: true -------------------------------------------------------------------------------- /.github/workflows/gorelease.yml: -------------------------------------------------------------------------------- 1 | # This script is provided by github.com/bool64/dev. 2 | name: gorelease 3 | on: 4 | pull_request: 5 | 6 | # Cancel the workflow in progress in newer build is about to start. 7 | concurrency: 8 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 9 | cancel-in-progress: true 10 | 11 | env: 12 | GO_VERSION: stable 13 | jobs: 14 | gorelease: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Install Go 18 | uses: actions/setup-go@v5 19 | with: 20 | go-version: ${{ env.GO_VERSION }} 21 | - name: Checkout code 22 | uses: actions/checkout@v4 23 | - name: Gorelease cache 24 | uses: actions/cache@v4 25 | with: 26 | path: | 27 | ~/go/bin/gorelease 28 | key: ${{ runner.os }}-gorelease-generic 29 | - name: Gorelease 30 | id: gorelease 31 | run: | 32 | test -e ~/go/bin/gorelease || go install golang.org/x/exp/cmd/gorelease@latest 33 | OUTPUT=$(gorelease 2>&1 || exit 0) 34 | echo "${OUTPUT}" 35 | echo "report<> $GITHUB_OUTPUT && echo "$OUTPUT" >> $GITHUB_OUTPUT && echo "EOF" >> $GITHUB_OUTPUT 36 | - name: Comment report 37 | continue-on-error: true 38 | uses: marocchino/sticky-pull-request-comment@v2 39 | with: 40 | header: gorelease 41 | message: | 42 | ### Go API Changes 43 | 44 |
45 |             ${{ steps.gorelease.outputs.report }}
46 |             
-------------------------------------------------------------------------------- /.github/workflows/test-examples.yml: -------------------------------------------------------------------------------- 1 | name: test-examples 2 | on: 3 | push: 4 | branches: 5 | - master 6 | - main 7 | pull_request: 8 | env: 9 | GO111MODULE: "on" 10 | jobs: 11 | test: 12 | strategy: 13 | matrix: 14 | go-version: [ stable ] 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Install Go stable 18 | uses: actions/setup-go@v5 19 | with: 20 | go-version: ${{ matrix.go-version }} 21 | - name: Checkout code 22 | uses: actions/checkout@v4 23 | - name: Go cache 24 | uses: actions/cache@v4 25 | with: 26 | # In order: 27 | # * Module download cache 28 | # * Build cache (Linux) 29 | path: | 30 | ~/go/pkg/mod 31 | ~/.cache/go-build 32 | key: ${{ runner.os }}-go-cache-ex-${{ hashFiles('**/go.sum') }} 33 | restore-keys: | 34 | ${{ runner.os }}-go-cache 35 | - name: Test Examples 36 | run: cd _examples && go test -race ./... 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | /*.coverprofile 3 | /.vscode 4 | /bench-*.txt 5 | /vendor 6 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | # See https://github.com/golangci/golangci-lint/blob/master/.golangci.example.yml 2 | run: 3 | tests: true 4 | 5 | linters-settings: 6 | errcheck: 7 | check-type-assertions: true 8 | check-blank: true 9 | gocyclo: 10 | min-complexity: 20 11 | dupl: 12 | threshold: 100 13 | misspell: 14 | locale: US 15 | unparam: 16 | check-exported: true 17 | 18 | linters: 19 | enable-all: true 20 | disable: 21 | - intrange 22 | - copyloopvar 23 | - lll 24 | - nilnil 25 | - err113 26 | - cyclop 27 | - gochecknoglobals 28 | - wrapcheck 29 | - paralleltest 30 | - forbidigo 31 | - forcetypeassert 32 | - varnamelen 33 | - tagliatelle 34 | - errname 35 | - ireturn 36 | - exhaustruct 37 | - nonamedreturns 38 | - testableexamples 39 | - dupword 40 | - depguard 41 | - tagalign 42 | - mnd 43 | - testifylint 44 | - recvcheck 45 | 46 | issues: 47 | exclude-use-default: false 48 | exclude-rules: 49 | - linters: 50 | - staticcheck 51 | path: ".go" 52 | text: "\"io/ioutil\" has been deprecated since Go 1.19" # Keeping backwards compatibility with go1.13. 53 | - linters: 54 | - errcheck 55 | - canonicalheader 56 | - testifylint 57 | - gomnd 58 | - mnd 59 | - goconst 60 | - noctx 61 | - funlen 62 | - dupl 63 | - unused 64 | - unparam 65 | path: "_test.go" 66 | - linters: 67 | - errcheck # Error checking omitted for brevity. 68 | - gosec 69 | path: "example_" 70 | - linters: 71 | - revive 72 | text: "unused-parameter: parameter" 73 | 74 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Viacheslav Poturaev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | #GOLANGCI_LINT_VERSION := "v1.64.5" # Optional configuration to pinpoint golangci-lint version. 2 | 3 | # The head of Makefile determines location of dev-go to include standard targets. 4 | GO ?= go 5 | export GO111MODULE = on 6 | 7 | ifneq "$(GOFLAGS)" "" 8 | $(info GOFLAGS: ${GOFLAGS}) 9 | endif 10 | 11 | ifneq "$(wildcard ./vendor )" "" 12 | $(info Using vendor) 13 | modVendor = -mod=vendor 14 | ifeq (,$(findstring -mod,$(GOFLAGS))) 15 | export GOFLAGS := ${GOFLAGS} ${modVendor} 16 | endif 17 | ifneq "$(wildcard ./vendor/github.com/bool64/dev)" "" 18 | DEVGO_PATH := ./vendor/github.com/bool64/dev 19 | endif 20 | endif 21 | 22 | ifeq ($(DEVGO_PATH),) 23 | DEVGO_PATH := $(shell GO111MODULE=on $(GO) list ${modVendor} -f '{{.Dir}}' -m github.com/bool64/dev) 24 | ifeq ($(DEVGO_PATH),) 25 | $(info Module github.com/bool64/dev not found, downloading.) 26 | DEVGO_PATH := $(shell export GO111MODULE=on && $(GO) get github.com/bool64/dev && $(GO) list -f '{{.Dir}}' -m github.com/bool64/dev) 27 | endif 28 | endif 29 | 30 | BENCH_COUNT ?= 6 31 | REF_NAME ?= $(shell git symbolic-ref HEAD --short | tr / - 2>/dev/null) 32 | 33 | -include $(DEVGO_PATH)/makefiles/main.mk 34 | -include $(DEVGO_PATH)/makefiles/lint.mk 35 | -include $(DEVGO_PATH)/makefiles/test-unit.mk 36 | -include $(DEVGO_PATH)/makefiles/bench.mk 37 | -include $(DEVGO_PATH)/makefiles/reset-ci.mk 38 | 39 | # Add your custom targets here. 40 | 41 | ## Run tests 42 | test: test-unit test-examples 43 | 44 | test-examples: 45 | cd _examples && $(GO) test -race ./... 46 | 47 | ## Run benchmark for app examples, iterations count controlled by BENCH_COUNT, default 5. 48 | bench-run-examples: 49 | @cd _examples && set -o pipefail && $(GO) test -bench=. -count=$(BENCH_COUNT) -run=^a ./... | tee ../bench-examples-$(REF_NAME).txt 50 | 51 | ## Show result of benchmark for app examples. 52 | bench-stat-examples: bench-stat-cli 53 | @test -s bench-examples-master.txt && benchstat bench-examples-master.txt bench-examples-$(REF_NAME).txt || benchstat bench-examples-$(REF_NAME).txt 54 | -------------------------------------------------------------------------------- /UPGRADE.md: -------------------------------------------------------------------------------- 1 | # Breaking Changes Guide 2 | 3 | ## v0.2.0 4 | 5 | * Router dependency `github.com/go-chi/chi` upgraded to the latest module, please change relevant imports 6 | to `github.com/go-chi/chi/v5`. -------------------------------------------------------------------------------- /_examples/.gitignore: -------------------------------------------------------------------------------- 1 | *_last_run.json 2 | -------------------------------------------------------------------------------- /_examples/.golangci.yml: -------------------------------------------------------------------------------- 1 | # See https://github.com/golangci/golangci-lint/blob/master/.golangci.example.yml 2 | run: 3 | tests: true 4 | 5 | linters-settings: 6 | errcheck: 7 | check-type-assertions: true 8 | check-blank: true 9 | gocyclo: 10 | min-complexity: 20 11 | dupl: 12 | threshold: 100 13 | misspell: 14 | locale: US 15 | unused: 16 | check-exported: false 17 | unparam: 18 | check-exported: true 19 | 20 | linters: 21 | enable-all: true 22 | disable: 23 | - funlen 24 | - exhaustruct 25 | - musttag 26 | - nonamedreturns 27 | - goerr113 28 | - lll 29 | - maligned 30 | - gochecknoglobals 31 | - gomnd 32 | - wrapcheck 33 | - paralleltest 34 | - forbidigo 35 | - exhaustivestruct 36 | - interfacer # deprecated 37 | - forcetypeassert 38 | - scopelint # deprecated 39 | - ifshort # too many false positives 40 | - golint # deprecated 41 | - varnamelen 42 | - tagliatelle 43 | - errname 44 | - ireturn 45 | 46 | issues: 47 | exclude-use-default: false 48 | exclude-rules: 49 | - linters: 50 | - gomnd 51 | - goconst 52 | - goerr113 53 | - noctx 54 | - funlen 55 | - dupl 56 | path: "_test.go" 57 | 58 | -------------------------------------------------------------------------------- /_examples/Makefile: -------------------------------------------------------------------------------- 1 | #GOLANGCI_LINT_VERSION := "v1.43.0" # Optional configuration to pinpoint golangci-lint version. 2 | 3 | # The head of Makefile determines location of dev-go to include standard targets. 4 | GO ?= go 5 | export GO111MODULE = on 6 | 7 | ifneq "$(GOFLAGS)" "" 8 | $(info GOFLAGS: ${GOFLAGS}) 9 | endif 10 | 11 | ifneq "$(wildcard ./vendor )" "" 12 | $(info Using vendor) 13 | modVendor = -mod=vendor 14 | ifeq (,$(findstring -mod,$(GOFLAGS))) 15 | export GOFLAGS := ${GOFLAGS} ${modVendor} 16 | endif 17 | ifneq "$(wildcard ./vendor/github.com/bool64/dev)" "" 18 | DEVGO_PATH := ./vendor/github.com/bool64/dev 19 | endif 20 | endif 21 | 22 | ifeq ($(DEVGO_PATH),) 23 | DEVGO_PATH := $(shell GO111MODULE=on $(GO) list ${modVendor} -f '{{.Dir}}' -m github.com/bool64/dev) 24 | ifeq ($(DEVGO_PATH),) 25 | $(info Module github.com/bool64/dev not found, downloading.) 26 | DEVGO_PATH := $(shell export GO111MODULE=on && $(GO) mod tidy && $(GO) list -f '{{.Dir}}' -m github.com/bool64/dev) 27 | endif 28 | endif 29 | 30 | -include $(DEVGO_PATH)/makefiles/main.mk 31 | -include $(DEVGO_PATH)/makefiles/lint.mk 32 | -include $(DEVGO_PATH)/makefiles/test-unit.mk 33 | -include $(DEVGO_PATH)/makefiles/bench.mk 34 | -include $(DEVGO_PATH)/makefiles/reset-ci.mk 35 | 36 | # Add your custom targets here. 37 | 38 | ## Run tests 39 | test: test-unit 40 | -------------------------------------------------------------------------------- /_examples/advanced-generic-openapi31/dummy.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/swaggest/usecase" 7 | ) 8 | 9 | func dummy() usecase.Interactor { 10 | u := usecase.NewInteractor(func(ctx context.Context, input struct{}, output *struct{}) error { 11 | return nil 12 | }) 13 | u.SetTags("Other") 14 | 15 | return u 16 | } 17 | -------------------------------------------------------------------------------- /_examples/advanced-generic-openapi31/error_response.go: -------------------------------------------------------------------------------- 1 | //go:build go1.18 2 | 3 | package main 4 | 5 | import ( 6 | "context" 7 | "errors" 8 | 9 | "github.com/bool64/ctxd" 10 | "github.com/swaggest/usecase" 11 | "github.com/swaggest/usecase/status" 12 | ) 13 | 14 | type customErr struct { 15 | Message string `json:"msg"` 16 | Details map[string]interface{} `json:"details,omitempty"` 17 | } 18 | 19 | func errorResponse() usecase.Interactor { 20 | type errType struct { 21 | Type string `query:"type" enum:"ok,invalid_argument,conflict" required:"true"` 22 | } 23 | 24 | type okResp struct { 25 | Status string `json:"status"` 26 | } 27 | 28 | u := usecase.NewInteractor(func(ctx context.Context, in errType, out *okResp) (err error) { 29 | switch in.Type { 30 | case "ok": 31 | out.Status = "ok" 32 | case "invalid_argument": 33 | return status.Wrap(errors.New("bad value for foo"), status.InvalidArgument) 34 | case "conflict": 35 | return status.Wrap(ctxd.NewError(ctx, "conflict", "foo", "bar"), 36 | status.AlreadyExists) 37 | } 38 | 39 | return nil 40 | }) 41 | 42 | u.SetTitle("Declare Expected Errors") 43 | u.SetDescription("This use case demonstrates documentation of expected errors.") 44 | u.SetExpectedErrors(status.InvalidArgument, anotherErr{}, status.FailedPrecondition, 45 | status.WithDescription(status.AlreadyExists, "Response with custom description.")) 46 | u.SetTags("Response") 47 | 48 | return u 49 | } 50 | 51 | // anotherErr is another custom error. 52 | type anotherErr struct { 53 | Foo int `json:"foo"` 54 | } 55 | 56 | func (anotherErr) Error() string { 57 | return "foo happened" 58 | } 59 | -------------------------------------------------------------------------------- /_examples/advanced-generic-openapi31/file_multi_upload.go: -------------------------------------------------------------------------------- 1 | //go:build go1.18 2 | 3 | package main 4 | 5 | import ( 6 | "context" 7 | "mime/multipart" 8 | "net/textproto" 9 | 10 | "github.com/swaggest/usecase" 11 | ) 12 | 13 | func fileMultiUploader() usecase.Interactor { 14 | type upload struct { 15 | Simple string `formData:"simple" description:"Simple scalar value in body."` 16 | Query int `query:"in_query" description:"Simple scalar value in query."` 17 | Uploads1 []*multipart.FileHeader `formData:"uploads1" description:"Uploads with *multipart.FileHeader."` 18 | Uploads2 []multipart.File `formData:"uploads2" description:"Uploads with multipart.File."` 19 | } 20 | 21 | type info struct { 22 | Filenames []string `json:"filenames"` 23 | Headers []textproto.MIMEHeader `json:"headers"` 24 | Sizes []int64 `json:"sizes"` 25 | Upload1Peeks []string `json:"peeks1"` 26 | Upload2Peeks []string `json:"peeks2"` 27 | Simple string `json:"simple"` 28 | Query int `json:"inQuery"` 29 | } 30 | 31 | u := usecase.NewInteractor(func(ctx context.Context, in upload, out *info) (err error) { 32 | out.Query = in.Query 33 | out.Simple = in.Simple 34 | for _, o := range in.Uploads1 { 35 | out.Filenames = append(out.Filenames, o.Filename) 36 | out.Headers = append(out.Headers, o.Header) 37 | out.Sizes = append(out.Sizes, o.Size) 38 | 39 | f, err := o.Open() 40 | if err != nil { 41 | return err 42 | } 43 | p := make([]byte, 100) 44 | _, err = f.Read(p) 45 | if err != nil { 46 | return err 47 | } 48 | 49 | out.Upload1Peeks = append(out.Upload1Peeks, string(p)) 50 | 51 | err = f.Close() 52 | if err != nil { 53 | return err 54 | } 55 | } 56 | 57 | for _, o := range in.Uploads2 { 58 | p := make([]byte, 100) 59 | _, err = o.Read(p) 60 | if err != nil { 61 | return err 62 | } 63 | 64 | out.Upload2Peeks = append(out.Upload2Peeks, string(p)) 65 | err = o.Close() 66 | if err != nil { 67 | return err 68 | } 69 | } 70 | 71 | return nil 72 | }) 73 | 74 | u.SetTitle("Files Uploads With 'multipart/form-data'") 75 | u.SetTags("Request") 76 | 77 | return u 78 | } 79 | -------------------------------------------------------------------------------- /_examples/advanced-generic-openapi31/file_upload.go: -------------------------------------------------------------------------------- 1 | //go:build go1.18 2 | 3 | package main 4 | 5 | import ( 6 | "context" 7 | "mime/multipart" 8 | "net/textproto" 9 | 10 | "github.com/swaggest/usecase" 11 | ) 12 | 13 | func fileUploader() usecase.Interactor { 14 | type upload struct { 15 | Simple string `formData:"simple" description:"Simple scalar value in body."` 16 | Query int `query:"in_query" description:"Simple scalar value in query."` 17 | Upload1 *multipart.FileHeader `formData:"upload1" description:"Upload with *multipart.FileHeader."` 18 | Upload2 multipart.File `formData:"upload2" description:"Upload with multipart.File."` 19 | } 20 | 21 | type info struct { 22 | Filename string `json:"filename"` 23 | Header textproto.MIMEHeader `json:"header"` 24 | Size int64 `json:"size"` 25 | Upload1Peek string `json:"peek1"` 26 | Upload2Peek string `json:"peek2"` 27 | Simple string `json:"simple"` 28 | Query int `json:"inQuery"` 29 | } 30 | 31 | u := usecase.NewInteractor(func(ctx context.Context, in upload, out *info) (err error) { 32 | out.Query = in.Query 33 | out.Simple = in.Simple 34 | if in.Upload1 == nil { 35 | return nil 36 | } 37 | 38 | out.Filename = in.Upload1.Filename 39 | out.Header = in.Upload1.Header 40 | out.Size = in.Upload1.Size 41 | 42 | f, err := in.Upload1.Open() 43 | if err != nil { 44 | return err 45 | } 46 | 47 | defer func() { 48 | clErr := f.Close() 49 | if clErr != nil && err == nil { 50 | err = clErr 51 | } 52 | 53 | clErr = in.Upload2.Close() 54 | if clErr != nil && err == nil { 55 | err = clErr 56 | } 57 | }() 58 | 59 | p := make([]byte, 100) 60 | _, err = f.Read(p) 61 | if err != nil { 62 | return err 63 | } 64 | 65 | out.Upload1Peek = string(p) 66 | 67 | p = make([]byte, 100) 68 | _, err = in.Upload2.Read(p) 69 | if err != nil { 70 | return err 71 | } 72 | 73 | out.Upload2Peek = string(p) 74 | 75 | return nil 76 | }) 77 | 78 | u.SetTitle("File Upload With 'multipart/form-data'") 79 | u.SetTags("Request") 80 | 81 | return u 82 | } 83 | -------------------------------------------------------------------------------- /_examples/advanced-generic-openapi31/form.go: -------------------------------------------------------------------------------- 1 | //go:build go1.18 2 | 3 | package main 4 | 5 | import ( 6 | "context" 7 | 8 | "github.com/swaggest/usecase" 9 | ) 10 | 11 | func form() usecase.Interactor { 12 | type form struct { 13 | ID int `form:"id"` 14 | Name string `form:"name"` 15 | } 16 | 17 | type output struct { 18 | ID int `json:"id"` 19 | Name string `json:"name"` 20 | } 21 | 22 | u := usecase.NewInteractor(func(ctx context.Context, in form, out *output) error { 23 | out.ID = in.ID 24 | out.Name = in.Name 25 | 26 | return nil 27 | }) 28 | 29 | u.SetTitle("Request With Form") 30 | u.SetDescription("The `form` field tag acts as `query` and `formData`, with priority on `formData`.\n\n" + 31 | "It is decoded with `http.Request.Form` values.") 32 | u.SetTags("Request") 33 | 34 | return u 35 | } 36 | -------------------------------------------------------------------------------- /_examples/advanced-generic-openapi31/form_or_json.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/swaggest/usecase" 7 | ) 8 | 9 | type formOrJSONInput struct { 10 | Field1 string `json:"field1" formData:"field1" required:"true"` 11 | Field2 int `json:"field2" formData:"field2" required:"true"` 12 | Field3 string `path:"path" required:"true"` 13 | } 14 | 15 | func (formOrJSONInput) ForceJSONRequestBody() {} 16 | 17 | func formOrJSON() usecase.Interactor { 18 | type formOrJSONOutput struct { 19 | F1 string `json:"f1"` 20 | F2 int `json:"f2"` 21 | F3 string `json:"f3"` 22 | } 23 | 24 | u := usecase.NewInteractor(func(ctx context.Context, input formOrJSONInput, output *formOrJSONOutput) error { 25 | output.F1 = input.Field1 26 | output.F2 = input.Field2 27 | output.F3 = input.Field3 28 | 29 | return nil 30 | }) 31 | 32 | u.SetTags("Request") 33 | u.SetDescription("This endpoint can accept both form and json requests with the same input structure.") 34 | 35 | return u 36 | } 37 | -------------------------------------------------------------------------------- /_examples/advanced-generic-openapi31/form_or_json_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/bool64/httptestbench" 9 | "github.com/valyala/fasthttp" 10 | ) 11 | 12 | func Benchmark_formOrJSON(b *testing.B) { 13 | r := NewRouter() 14 | 15 | srv := httptest.NewServer(r) 16 | defer srv.Close() 17 | 18 | b.Run("form", func(b *testing.B) { 19 | httptestbench.RoundTrip(b, 50, func(i int, req *fasthttp.Request) { 20 | req.Header.SetMethod(http.MethodPost) 21 | req.SetRequestURI(srv.URL + "/form-or-json/abc") 22 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 23 | req.SetBody([]byte(`field1=def&field2=123`)) 24 | }, func(i int, resp *fasthttp.Response) bool { 25 | return resp.StatusCode() == http.StatusOK 26 | }) 27 | }) 28 | 29 | b.Run("json", func(b *testing.B) { 30 | httptestbench.RoundTrip(b, 50, func(i int, req *fasthttp.Request) { 31 | req.Header.SetMethod(http.MethodPost) 32 | req.SetRequestURI(srv.URL + "/form-or-json/abc") 33 | req.Header.Set("Content-Type", "application/json") 34 | req.SetBody([]byte(`{"field1":"string","field2":0}`)) 35 | }, func(i int, resp *fasthttp.Response) bool { 36 | return resp.StatusCode() == http.StatusOK 37 | }) 38 | }) 39 | } 40 | -------------------------------------------------------------------------------- /_examples/advanced-generic-openapi31/gzip_pass_through.go: -------------------------------------------------------------------------------- 1 | //go:build go1.18 2 | 3 | package main 4 | 5 | import ( 6 | "context" 7 | 8 | "github.com/swaggest/rest/gzip" 9 | "github.com/swaggest/usecase" 10 | ) 11 | 12 | type gzipPassThroughInput struct { 13 | PlainStruct bool `query:"plainStruct" description:"Output plain structure instead of gzip container."` 14 | CountItems bool `query:"countItems" description:"Invokes internal decoding of compressed data."` 15 | } 16 | 17 | // gzipPassThroughOutput defers data to an accessor function instead of using struct directly. 18 | // This is necessary to allow containers that can data in binary wire-friendly format. 19 | type gzipPassThroughOutput interface { 20 | // Data should be accessed though an accessor to allow container interface. 21 | gzipPassThroughStruct() gzipPassThroughStruct 22 | } 23 | 24 | // gzipPassThroughStruct represents the actual structure that is held in the container 25 | // and implements gzipPassThroughOutput to be directly useful in output. 26 | type gzipPassThroughStruct struct { 27 | Header string `header:"X-Header" json:"-"` 28 | ID int `json:"id"` 29 | Text []string `json:"text"` 30 | } 31 | 32 | func (d gzipPassThroughStruct) gzipPassThroughStruct() gzipPassThroughStruct { 33 | return d 34 | } 35 | 36 | // gzipPassThroughContainer is wrapping gzip.JSONContainer and implements gzipPassThroughOutput. 37 | type gzipPassThroughContainer struct { 38 | Header string `header:"X-Header" json:"-"` 39 | gzip.JSONContainer 40 | } 41 | 42 | func (dc gzipPassThroughContainer) gzipPassThroughStruct() gzipPassThroughStruct { 43 | var p gzipPassThroughStruct 44 | 45 | err := dc.UnpackJSON(&p) 46 | if err != nil { 47 | panic(err) 48 | } 49 | 50 | return p 51 | } 52 | 53 | func directGzip() usecase.Interactor { 54 | // Prepare moderately big JSON, resulting JSON payload is ~67KB. 55 | rawData := gzipPassThroughStruct{ 56 | ID: 123, 57 | } 58 | for i := 0; i < 400; i++ { 59 | rawData.Text = append(rawData.Text, "Quis autem vel eum iure reprehenderit, qui in ea voluptate velit esse, "+ 60 | "quam nihil molestiae consequatur, vel illum, qui dolorem eum fugiat, quo voluptas nulla pariatur?") 61 | } 62 | 63 | // Precompute compressed data container. Generally this step should be owned by a caching storage of data. 64 | dataFromCache := gzipPassThroughContainer{} 65 | 66 | err := dataFromCache.PackJSON(rawData) 67 | if err != nil { 68 | panic(err) 69 | } 70 | 71 | u := usecase.NewInteractor(func(ctx context.Context, in gzipPassThroughInput, out *gzipPassThroughOutput) error { 72 | if in.PlainStruct { 73 | o := rawData 74 | o.Header = "cba" 75 | *out = o 76 | } else { 77 | o := dataFromCache 78 | o.Header = "abc" 79 | *out = o 80 | } 81 | 82 | // Imitating an internal read operation on data in container. 83 | if in.CountItems { 84 | cnt := len((*out).gzipPassThroughStruct().Text) 85 | println("items: ", cnt) 86 | } 87 | 88 | return nil 89 | }) 90 | u.SetTags("Response") 91 | 92 | return u 93 | } 94 | -------------------------------------------------------------------------------- /_examples/advanced-generic-openapi31/html_response.go: -------------------------------------------------------------------------------- 1 | //go:build go1.18 2 | 3 | package main 4 | 5 | import ( 6 | "context" 7 | "html/template" 8 | "io" 9 | 10 | "github.com/swaggest/usecase" 11 | ) 12 | 13 | type htmlResponseOutput struct { 14 | ID int 15 | Filter string 16 | Title string 17 | Items []string 18 | AntiHeader bool `header:"X-Anti-Header"` 19 | 20 | writer io.Writer 21 | } 22 | 23 | func (o *htmlResponseOutput) SetWriter(w io.Writer) { 24 | o.writer = w 25 | } 26 | 27 | func (o *htmlResponseOutput) Render(tmpl *template.Template) error { 28 | return tmpl.Execute(o.writer, o) 29 | } 30 | 31 | func htmlResponse() usecase.Interactor { 32 | type htmlResponseInput struct { 33 | ID int `path:"id"` 34 | Filter string `query:"filter"` 35 | Header bool `header:"X-Header"` 36 | } 37 | 38 | const tpl = ` 39 | 40 | 41 | 42 | {{.Title}} 43 | 44 | 45 | Next {{.Title}}
46 | {{range .Items}}
{{ . }}
{{else}}
no rows
{{end}} 47 | 48 | ` 49 | 50 | tmpl, err := template.New("htmlResponse").Parse(tpl) 51 | if err != nil { 52 | panic(err) 53 | } 54 | 55 | u := usecase.NewInteractor(func(ctx context.Context, in htmlResponseInput, out *htmlResponseOutput) (err error) { 56 | out.AntiHeader = !in.Header 57 | out.Filter = in.Filter 58 | out.ID = in.ID + 1 59 | out.Title = "Foo" 60 | out.Items = []string{"foo", "bar", "baz"} 61 | 62 | return out.Render(tmpl) 63 | }) 64 | 65 | u.SetTitle("Request With HTML Response") 66 | u.SetDescription("Request with templated HTML response.") 67 | u.SetTags("Response") 68 | 69 | return u 70 | } 71 | -------------------------------------------------------------------------------- /_examples/advanced-generic-openapi31/html_response_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/bool64/httptestbench" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | "github.com/valyala/fasthttp" 13 | ) 14 | 15 | func Test_htmlResponse(t *testing.T) { 16 | r := NewRouter() 17 | 18 | srv := httptest.NewServer(r) 19 | defer srv.Close() 20 | 21 | resp, err := http.Get(srv.URL + "/html-response/123?filter=feel") 22 | require.NoError(t, err) 23 | 24 | assert.Equal(t, resp.StatusCode, http.StatusOK) 25 | 26 | body, err := io.ReadAll(resp.Body) 27 | assert.NoError(t, err) 28 | assert.NoError(t, resp.Body.Close()) 29 | 30 | assert.Equal(t, "true", resp.Header.Get("X-Anti-Header")) 31 | assert.Equal(t, "text/html", resp.Header.Get("Content-Type")) 32 | assert.Equal(t, ` 33 | 34 | 35 | 36 | Foo 37 | 38 | 39 | Next Foo
40 |
foo
bar
baz
41 | 42 | `, string(body), string(body)) 43 | } 44 | 45 | func Test_htmlResponse_HEAD(t *testing.T) { 46 | r := NewRouter() 47 | 48 | srv := httptest.NewServer(r) 49 | defer srv.Close() 50 | 51 | resp, err := http.Head(srv.URL + "/html-response/123?filter=feel") 52 | require.NoError(t, err) 53 | 54 | assert.Equal(t, resp.StatusCode, http.StatusOK) 55 | 56 | body, err := io.ReadAll(resp.Body) 57 | assert.NoError(t, err) 58 | assert.NoError(t, resp.Body.Close()) 59 | 60 | assert.Equal(t, "true", resp.Header.Get("X-Anti-Header")) 61 | assert.Equal(t, "text/html", resp.Header.Get("Content-Type")) 62 | assert.Empty(t, body) 63 | } 64 | 65 | // Benchmark_htmlResponse-12 89209 12348 ns/op 0.3801 50%:ms 1.119 90%:ms 2.553 99%:ms 3.877 99.9%:ms 370.0 B:rcvd/op 108.0 B:sent/op 80973 rps 8279 B/op 144 allocs/op. 66 | func Benchmark_htmlResponse(b *testing.B) { 67 | r := NewRouter() 68 | 69 | srv := httptest.NewServer(r) 70 | defer srv.Close() 71 | 72 | httptestbench.RoundTrip(b, 50, func(i int, req *fasthttp.Request) { 73 | req.SetRequestURI(srv.URL + "/html-response/123?filter=feel") 74 | req.Header.Set("X-Header", "true") 75 | }, func(i int, resp *fasthttp.Response) bool { 76 | return resp.StatusCode() == http.StatusOK 77 | }) 78 | } 79 | -------------------------------------------------------------------------------- /_examples/advanced-generic-openapi31/json_body.go: -------------------------------------------------------------------------------- 1 | //go:build go1.18 2 | 3 | package main 4 | 5 | import ( 6 | "context" 7 | 8 | "github.com/swaggest/jsonschema-go" 9 | "github.com/swaggest/usecase" 10 | ) 11 | 12 | func jsonBody() usecase.Interactor { 13 | type JSONPayload struct { 14 | ID int `json:"id"` 15 | Name string `json:"name"` 16 | } 17 | 18 | type inputWithJSON struct { 19 | Header string `header:"X-Header" description:"Simple scalar value in header."` 20 | Query jsonschema.Date `query:"in_query" description:"Simple scalar value in query."` 21 | Path string `path:"in-path" description:"Simple scalar value in path"` 22 | NamedStruct JSONPayload `json:"namedStruct" deprecated:"true"` 23 | JSONPayload 24 | } 25 | 26 | type outputWithJSON struct { 27 | Header string `json:"inHeader"` 28 | Query jsonschema.Date `json:"inQuery" deprecated:"true"` 29 | Path string `json:"inPath"` 30 | JSONPayload 31 | } 32 | 33 | u := usecase.NewInteractor(func(ctx context.Context, in inputWithJSON, out *outputWithJSON) (err error) { 34 | out.Query = in.Query 35 | out.Header = in.Header 36 | out.Path = in.Path 37 | out.JSONPayload = in.JSONPayload 38 | 39 | return nil 40 | }) 41 | 42 | u.SetTitle("Request With JSON Body") 43 | u.SetDescription("Request with JSON body and query/header/path params, response with JSON body and data from request.") 44 | u.SetTags("Request") 45 | 46 | return u 47 | } 48 | -------------------------------------------------------------------------------- /_examples/advanced-generic-openapi31/json_body_manual.go: -------------------------------------------------------------------------------- 1 | //go:build go1.18 2 | 3 | package main 4 | 5 | import ( 6 | "context" 7 | "encoding/json" 8 | "errors" 9 | "fmt" 10 | "io" 11 | "log" 12 | "net/http" 13 | 14 | "github.com/go-chi/chi/v5" 15 | "github.com/swaggest/jsonschema-go" 16 | "github.com/swaggest/rest/request" 17 | "github.com/swaggest/usecase" 18 | ) 19 | 20 | func jsonBodyManual() usecase.Interactor { 21 | type outputWithJSON struct { 22 | Header string `json:"inHeader"` 23 | Query jsonschema.Date `json:"inQuery" deprecated:"true"` 24 | Path string `json:"inPath"` 25 | JSONPayload 26 | } 27 | 28 | u := usecase.NewInteractor(func(ctx context.Context, in inputWithJSON, out *outputWithJSON) (err error) { 29 | out.Query = in.Query 30 | out.Header = in.Header 31 | out.Path = in.Path 32 | out.JSONPayload = in.JSONPayload 33 | 34 | return nil 35 | }) 36 | 37 | u.SetTitle("Request With JSON Body and manual decoder") 38 | u.SetDescription("Request with JSON body and query/header/path params, response with JSON body and data from request.") 39 | u.SetTags("Request") 40 | 41 | return u 42 | } 43 | 44 | type JSONPayload struct { 45 | ID int `json:"id"` 46 | Name string `json:"name"` 47 | } 48 | 49 | type inputWithJSON struct { 50 | Header string `header:"X-Header" description:"Simple scalar value in header."` 51 | Query jsonschema.Date `query:"in_query" description:"Simple scalar value in query."` 52 | Path string `path:"in-path" description:"Simple scalar value in path"` 53 | NamedStruct JSONPayload `json:"namedStruct" deprecated:"true"` 54 | JSONPayload 55 | } 56 | 57 | var _ request.Loader = &inputWithJSON{} 58 | 59 | func (i *inputWithJSON) LoadFromHTTPRequest(r *http.Request) (err error) { 60 | defer func() { 61 | if err := r.Body.Close(); err != nil { 62 | log.Printf("failed to close request body: %s", err.Error()) 63 | } 64 | }() 65 | 66 | b, err := io.ReadAll(r.Body) 67 | if err != nil { 68 | return fmt.Errorf("failed to read request body: %w", err) 69 | } 70 | 71 | if err = json.Unmarshal(b, i); err != nil { 72 | return fmt.Errorf("failed to unmarshal request body: %w", err) 73 | } 74 | 75 | i.Header = r.Header.Get("X-Header") 76 | if err := i.Query.UnmarshalText([]byte(r.URL.Query().Get("in_query"))); err != nil { 77 | return fmt.Errorf("failed to decode in_query %q: %w", r.URL.Query().Get("in_query"), err) 78 | } 79 | 80 | if routeCtx := chi.RouteContext(r.Context()); routeCtx != nil { 81 | i.Path = routeCtx.URLParam("in-path") 82 | } else { 83 | return errors.New("missing path params in context") 84 | } 85 | 86 | return nil 87 | } 88 | -------------------------------------------------------------------------------- /_examples/advanced-generic-openapi31/json_body_manual_test.go: -------------------------------------------------------------------------------- 1 | //go:build go1.18 2 | 3 | package main 4 | 5 | import ( 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | "github.com/bool64/httptestbench" 11 | "github.com/valyala/fasthttp" 12 | ) 13 | 14 | // Benchmark_jsonBodyManual-12 125672 8542 ns/op 208.0 B:rcvd/op 195.0 B:sent/op 117048 rps 4523 B/op 49 allocs/op. 15 | func Benchmark_jsonBodyManual(b *testing.B) { 16 | r := NewRouter() 17 | 18 | srv := httptest.NewServer(r) 19 | defer srv.Close() 20 | 21 | httptestbench.RoundTrip(b, 50, func(i int, req *fasthttp.Request) { 22 | req.Header.SetMethod(http.MethodPost) 23 | req.SetRequestURI(srv.URL + "/json-body-manual/abc?in_query=2006-01-02") 24 | req.Header.Set("Content-Type", "application/json") 25 | req.Header.Set("X-Header", "def") 26 | req.SetBody([]byte(`{"id":321,"name":"Jane"}`)) 27 | }, func(i int, resp *fasthttp.Response) bool { 28 | return resp.StatusCode() == http.StatusCreated 29 | }) 30 | } 31 | -------------------------------------------------------------------------------- /_examples/advanced-generic-openapi31/json_body_test.go: -------------------------------------------------------------------------------- 1 | //go:build go1.18 2 | 3 | package main 4 | 5 | import ( 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | "github.com/bool64/httptestbench" 11 | "github.com/valyala/fasthttp" 12 | ) 13 | 14 | // Benchmark_jsonBody-12 96762 12042 ns/op 208.0 B:rcvd/op 188.0 B:sent/op 83033 rps 10312 B/op 100 allocs/op. 15 | func Benchmark_jsonBody(b *testing.B) { 16 | r := NewRouter() 17 | 18 | srv := httptest.NewServer(r) 19 | defer srv.Close() 20 | 21 | httptestbench.RoundTrip(b, 50, func(i int, req *fasthttp.Request) { 22 | req.Header.SetMethod(http.MethodPost) 23 | req.SetRequestURI(srv.URL + "/json-body/abc?in_query=2006-01-02") 24 | req.Header.Set("Content-Type", "application/json") 25 | req.Header.Set("X-Header", "def") 26 | req.SetBody([]byte(`{"id":321,"name":"Jane"}`)) 27 | }, func(i int, resp *fasthttp.Response) bool { 28 | return resp.StatusCode() == http.StatusCreated 29 | }) 30 | } 31 | -------------------------------------------------------------------------------- /_examples/advanced-generic-openapi31/json_body_validation.go: -------------------------------------------------------------------------------- 1 | //go:build go1.18 2 | 3 | package main 4 | 5 | import ( 6 | "context" 7 | 8 | "github.com/swaggest/usecase" 9 | ) 10 | 11 | func jsonBodyValidation() usecase.Interactor { 12 | type JSONPayload struct { 13 | ID int `json:"id" minimum:"100"` 14 | Name string `json:"name" minLength:"3"` 15 | } 16 | 17 | type inputWithJSON struct { 18 | Header string `header:"X-Header" description:"Simple scalar value in header." minLength:"3"` 19 | Query int `query:"in_query" description:"Simple scalar value in query." minimum:"100"` 20 | Path string `path:"in-path" description:"Simple scalar value in path" minLength:"3"` 21 | JSONPayload 22 | } 23 | 24 | type outputWithJSON struct { 25 | Header string `json:"inHeader" minLength:"3"` 26 | Query int `json:"inQuery" minimum:"3"` 27 | Path string `json:"inPath" minLength:"3"` 28 | JSONPayload 29 | } 30 | 31 | u := usecase.NewInteractor(func(ctx context.Context, in inputWithJSON, out *outputWithJSON) (err error) { 32 | out.Query = in.Query 33 | out.Header = in.Header 34 | out.Path = in.Path 35 | out.JSONPayload = in.JSONPayload 36 | 37 | return nil 38 | }) 39 | 40 | u.SetTitle("Request With JSON Body and non-trivial validation") 41 | u.SetDescription("Request with JSON body and query/header/path params, response with JSON body and data from request.") 42 | u.SetTags("Request", "Response", "Validation") 43 | 44 | return u 45 | } 46 | -------------------------------------------------------------------------------- /_examples/advanced-generic-openapi31/json_body_validation_test.go: -------------------------------------------------------------------------------- 1 | //go:build go1.18 2 | 3 | package main 4 | 5 | import ( 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | "github.com/bool64/httptestbench" 11 | "github.com/valyala/fasthttp" 12 | ) 13 | 14 | // Benchmark_jsonBodyValidation-4 19126 60170 ns/op 194 B:rcvd/op 192 B:sent/op 16620 rps 18363 B/op 144 allocs/op. 15 | func Benchmark_jsonBodyValidation(b *testing.B) { 16 | r := NewRouter() 17 | 18 | srv := httptest.NewServer(r) 19 | defer srv.Close() 20 | 21 | httptestbench.RoundTrip(b, 50, func(i int, req *fasthttp.Request) { 22 | req.Header.SetMethod(http.MethodPost) 23 | req.SetRequestURI(srv.URL + "/json-body-validation/abc?in_query=123") 24 | req.Header.Set("Content-Type", "application/json") 25 | req.Header.Set("X-Header", "def") 26 | req.SetBody([]byte(`{"id":321,"name":"Jane"}`)) 27 | }, func(i int, resp *fasthttp.Response) bool { 28 | return resp.StatusCode() == http.StatusOK 29 | }) 30 | } 31 | -------------------------------------------------------------------------------- /_examples/advanced-generic-openapi31/json_map_body.go: -------------------------------------------------------------------------------- 1 | //go:build go1.18 2 | 3 | package main 4 | 5 | import ( 6 | "context" 7 | "encoding/json" 8 | 9 | "github.com/swaggest/usecase" 10 | ) 11 | 12 | type JSONMapPayload map[string]float64 13 | 14 | type jsonMapReq struct { 15 | Header string `header:"X-Header" description:"Simple scalar value in header."` 16 | Query int `query:"in_query" description:"Simple scalar value in query."` 17 | JSONMapPayload 18 | } 19 | 20 | func (j *jsonMapReq) UnmarshalJSON(data []byte) error { 21 | return json.Unmarshal(data, &j.JSONMapPayload) 22 | } 23 | 24 | func jsonMapBody() usecase.Interactor { 25 | type jsonOutput struct { 26 | Header string `json:"inHeader"` 27 | Query int `json:"inQuery"` 28 | Data JSONMapPayload `json:"data"` 29 | } 30 | 31 | u := usecase.NewInteractor(func(ctx context.Context, in jsonMapReq, out *jsonOutput) (err error) { 32 | out.Query = in.Query 33 | out.Header = in.Header 34 | out.Data = in.JSONMapPayload 35 | 36 | return nil 37 | }) 38 | 39 | u.SetTitle("Request With JSON Map In Body") 40 | u.SetTags("Request") 41 | 42 | return u 43 | } 44 | -------------------------------------------------------------------------------- /_examples/advanced-generic-openapi31/json_param.go: -------------------------------------------------------------------------------- 1 | //go:build go1.18 2 | 3 | package main 4 | 5 | import ( 6 | "context" 7 | 8 | "github.com/google/uuid" 9 | "github.com/swaggest/usecase" 10 | ) 11 | 12 | func jsonParam() usecase.Interactor { 13 | type JSONPayload struct { 14 | ID int `json:"id"` 15 | Name string `json:"name"` 16 | } 17 | 18 | type inputWithJSON struct { 19 | Header string `header:"X-Header" description:"Simple scalar value in header."` 20 | Query int `query:"in_query" description:"Simple scalar value in query."` 21 | Path string `path:"in-path" description:"Simple scalar value in path"` 22 | Cookie uuid.UUID `cookie:"in_cookie" description:"UUID in cookie."` 23 | Identity JSONPayload `query:"identity" description:"JSON value in query"` 24 | } 25 | 26 | type outputWithJSON struct { 27 | Header string `json:"inHeader"` 28 | Query int `json:"inQuery"` 29 | Path string `json:"inPath"` 30 | JSONPayload 31 | } 32 | 33 | u := usecase.NewInteractor(func(ctx context.Context, in inputWithJSON, out *outputWithJSON) (err error) { 34 | out.Query = in.Query 35 | out.Header = in.Header 36 | out.Path = in.Path 37 | out.JSONPayload = in.Identity 38 | 39 | return nil 40 | }) 41 | 42 | u.SetTitle("Request With JSON Query Parameter") 43 | u.SetDescription("Request with JSON body and query/header/path params, response with JSON body and data from request.") 44 | u.SetTags("Request") 45 | 46 | return u 47 | } 48 | -------------------------------------------------------------------------------- /_examples/advanced-generic-openapi31/json_slice_body.go: -------------------------------------------------------------------------------- 1 | //go:build go1.18 2 | 3 | package main 4 | 5 | import ( 6 | "context" 7 | "encoding/json" 8 | 9 | "github.com/swaggest/usecase" 10 | ) 11 | 12 | // JSONSlicePayload is an example non-scalar type without `json` tags. 13 | type JSONSlicePayload []int 14 | 15 | type jsonSliceReq struct { 16 | Header string `header:"X-Header" description:"Simple scalar value in header."` 17 | Query int `query:"in_query" description:"Simple scalar value in query."` 18 | JSONSlicePayload 19 | } 20 | 21 | func (j *jsonSliceReq) UnmarshalJSON(data []byte) error { 22 | return json.Unmarshal(data, &j.JSONSlicePayload) 23 | } 24 | 25 | func jsonSliceBody() usecase.Interactor { 26 | type jsonOutput struct { 27 | Header string `json:"inHeader"` 28 | Query int `json:"inQuery"` 29 | Data JSONSlicePayload `json:"data"` 30 | } 31 | 32 | u := usecase.NewInteractor(func(ctx context.Context, in jsonSliceReq, out *jsonOutput) (err error) { 33 | out.Query = in.Query 34 | out.Header = in.Header 35 | out.Data = in.JSONSlicePayload 36 | 37 | return nil 38 | }) 39 | 40 | u.SetTitle("Request With JSON Array In Body") 41 | u.SetTags("Request") 42 | 43 | return u 44 | } 45 | -------------------------------------------------------------------------------- /_examples/advanced-generic-openapi31/main.go: -------------------------------------------------------------------------------- 1 | //go:build go1.18 2 | 3 | package main 4 | 5 | import ( 6 | "context" 7 | "encoding/json" 8 | "errors" 9 | "log" 10 | "net/http" 11 | "os" 12 | "os/signal" 13 | "time" 14 | ) 15 | 16 | func main() { 17 | var srv http.Server 18 | 19 | idleConnsClosed := make(chan struct{}) 20 | go func() { 21 | sigint := make(chan os.Signal, 1) 22 | signal.Notify(sigint, os.Interrupt) 23 | <-sigint 24 | 25 | log.Println("Shutting down...") 26 | 27 | // We received an interrupt signal, shut down with up to 10 seconds of waiting for current requests to finish. 28 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 29 | defer cancel() 30 | 31 | if err := srv.Shutdown(ctx); err != nil { 32 | // Error from closing listeners, or context timeout: 33 | log.Printf("HTTP server Shutdown: %v", err) 34 | } 35 | log.Println("Shutdown complete") 36 | 37 | close(idleConnsClosed) 38 | }() 39 | 40 | r := NewRouter() 41 | 42 | // You can access OpenAPI schema of an instrumented *web.Service if you need. 43 | j, _ := json.Marshal(r.OpenAPISchema()) 44 | println("OpenAPI schema head:", string(j)[0:300], "...") 45 | 46 | srv.Handler = r 47 | srv.Addr = "localhost:8012" 48 | 49 | log.Println("http://localhost:8012/docs") 50 | if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { 51 | // Error starting or closing listener: 52 | log.Fatalf("HTTP server ListenAndServe: %v", err) 53 | } 54 | 55 | <-idleConnsClosed 56 | } 57 | -------------------------------------------------------------------------------- /_examples/advanced-generic-openapi31/no_validation.go: -------------------------------------------------------------------------------- 1 | //go:build go1.18 2 | 3 | package main 4 | 5 | import ( 6 | "context" 7 | 8 | "github.com/swaggest/usecase" 9 | ) 10 | 11 | func noValidation() usecase.Interactor { 12 | type inputPort struct { 13 | Header int `header:"X-Input"` 14 | Query bool `query:"q"` 15 | Data struct { 16 | Value string `json:"value"` 17 | } `json:"data"` 18 | } 19 | 20 | type outputPort struct { 21 | Header int `header:"X-Output" json:"-"` 22 | AnotherHeader bool `header:"X-Query" json:"-"` 23 | Data struct { 24 | Value string `json:"value"` 25 | } `json:"data"` 26 | } 27 | 28 | u := usecase.NewInteractor(func(ctx context.Context, in inputPort, out *outputPort) (err error) { 29 | out.Header = in.Header 30 | out.AnotherHeader = in.Query 31 | out.Data.Value = in.Data.Value 32 | 33 | return nil 34 | }) 35 | 36 | u.SetTitle("No Validation") 37 | u.SetDescription("Input/Output without validation.") 38 | u.SetTags("Request", "Response") 39 | 40 | return u 41 | } 42 | -------------------------------------------------------------------------------- /_examples/advanced-generic-openapi31/output_headers.go: -------------------------------------------------------------------------------- 1 | //go:build go1.18 2 | 3 | package main 4 | 5 | import ( 6 | "context" 7 | 8 | "github.com/swaggest/usecase" 9 | "github.com/swaggest/usecase/status" 10 | ) 11 | 12 | func outputHeaders() usecase.Interactor { 13 | type EmbeddedHeaders struct { 14 | Foo int `header:"X-foO,omitempty" json:"-" minimum:"10" required:"true" description:"Reduced by 20 in response."` 15 | } 16 | 17 | type headerOutput struct { 18 | EmbeddedHeaders 19 | Header string `header:"x-HeAdEr" json:"-" description:"Sample response header."` 20 | OmitEmpty int `header:"x-omit-empty,omitempty" json:"-" description:"Receives req value of X-Foo reduced by 30."` 21 | InBody string `json:"inBody" deprecated:"true"` 22 | Cookie int `cookie:"coo,httponly,path:/foo" json:"-"` 23 | } 24 | 25 | type headerInput struct { 26 | EmbeddedHeaders 27 | } 28 | 29 | u := usecase.NewInteractor(func(ctx context.Context, in headerInput, out *headerOutput) (err error) { 30 | out.Header = "abc" 31 | out.InBody = "def" 32 | out.Cookie = 123 33 | out.Foo = in.Foo - 20 34 | out.OmitEmpty = in.Foo - 30 35 | 36 | return nil 37 | }) 38 | 39 | u.SetTitle("Output With Headers") 40 | u.SetDescription("Output with headers.") 41 | u.SetTags("Response") 42 | u.SetExpectedErrors(status.Internal) 43 | 44 | return u 45 | } 46 | -------------------------------------------------------------------------------- /_examples/advanced-generic-openapi31/output_writer.go: -------------------------------------------------------------------------------- 1 | //go:build go1.18 2 | 3 | package main 4 | 5 | import ( 6 | "context" 7 | "encoding/csv" 8 | "net/http" 9 | 10 | "github.com/swaggest/rest" 11 | "github.com/swaggest/usecase" 12 | "github.com/swaggest/usecase/status" 13 | ) 14 | 15 | func outputCSVWriter() usecase.Interactor { 16 | type writerOutput struct { 17 | Header string `header:"X-Header" description:"Sample response header."` 18 | ContentHash string `header:"ETag" description:"Content hash."` 19 | usecase.OutputWithEmbeddedWriter 20 | } 21 | 22 | type writerInput struct { 23 | ContentHash string `header:"If-None-Match" description:"Content hash."` 24 | } 25 | 26 | u := usecase.NewInteractor(func(ctx context.Context, in writerInput, out *writerOutput) (err error) { 27 | contentHash := "abc123" // Pretending this is an actual content hash. 28 | 29 | if in.ContentHash == contentHash { 30 | return rest.HTTPCodeAsError(http.StatusNotModified) 31 | } 32 | 33 | out.Header = "abc" 34 | out.ContentHash = contentHash 35 | 36 | c := csv.NewWriter(out) 37 | 38 | return c.WriteAll([][]string{{"abc", "def", "hij"}, {"klm", "nop", "qrs"}}) 39 | }) 40 | 41 | u.SetTitle("Output With Stream Writer") 42 | u.SetDescription("Output with stream writer.") 43 | u.SetExpectedErrors(status.Internal, rest.HTTPCodeAsError(http.StatusNotModified)) 44 | u.SetTags("Response") 45 | 46 | return u 47 | } 48 | -------------------------------------------------------------------------------- /_examples/advanced-generic-openapi31/query_object.go: -------------------------------------------------------------------------------- 1 | //go:build go1.18 2 | 3 | package main 4 | 5 | import ( 6 | "context" 7 | 8 | "github.com/swaggest/usecase" 9 | ) 10 | 11 | func queryObject() usecase.Interactor { 12 | type jsonFilter struct { 13 | Foo string `json:"foo" maxLength:"5"` 14 | } 15 | 16 | type deepObjectFilter struct { 17 | Bar string `json:"bar" query:"bar" minLength:"3"` 18 | Baz *string `json:"baz,omitempty" query:"baz" minLength:"3"` 19 | } 20 | 21 | type inputQueryObject struct { 22 | Query map[int]float64 `query:"in_query" description:"Object value in query."` 23 | JSONMap map[int]float64 `query:"json_map" collectionFormat:"json" description:"JSON object (map) value in query."` 24 | JSONFilter jsonFilter `query:"json_filter" description:"JSON object (struct) value in query."` 25 | DeepObjectFilter deepObjectFilter `query:"deep_object_filter" description:"Deep object value in query params."` 26 | } 27 | 28 | type outputQueryObject struct { 29 | Query map[int]float64 `json:"inQuery"` 30 | JSONMap map[int]float64 `json:"jsonMap"` 31 | JSONFilter jsonFilter `json:"jsonFilter"` 32 | DeepObjectFilter deepObjectFilter `json:"deepObjectFilter"` 33 | } 34 | 35 | u := usecase.NewInteractor(func(ctx context.Context, in inputQueryObject, out *outputQueryObject) (err error) { 36 | out.Query = in.Query 37 | out.JSONMap = in.JSONMap 38 | out.JSONFilter = in.JSONFilter 39 | out.DeepObjectFilter = in.DeepObjectFilter 40 | 41 | return nil 42 | }) 43 | 44 | u.SetTitle("Request With Object As Query Parameter") 45 | u.SetTags("Request") 46 | 47 | return u 48 | } 49 | -------------------------------------------------------------------------------- /_examples/advanced-generic-openapi31/query_object_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | "github.com/swaggest/assertjson" 13 | ) 14 | 15 | func Test_queryObject(t *testing.T) { 16 | r := NewRouter() 17 | 18 | srv := httptest.NewServer(r) 19 | defer srv.Close() 20 | 21 | for _, tc := range []struct { 22 | name string 23 | url string 24 | code int 25 | resp string 26 | }{ 27 | { 28 | name: "validation_failed_deep_object", 29 | url: `/query-object?in_query[1]=0&in_query[2]=0&in_query[3]=0&json_filter={"foo":"strin"}&deep_object_filter[bar]=sd`, 30 | code: http.StatusBadRequest, 31 | resp: `{ 32 | "msg":"invalid argument: validation failed", 33 | "details":{"query:deep_object_filter":["#/bar: length must be \u003e= 3, but got 2"]} 34 | }`, 35 | }, 36 | { 37 | name: "validation_failed_deep_object_2", 38 | url: `/query-object?in_query[1]=0&in_query[2]=0&in_query[3]=0&json_filter={"foo":"strin"}&deep_object_filter[bar]=asd&deep_object_filter[baz]=sd`, 39 | code: http.StatusBadRequest, 40 | resp: `{ 41 | "msg":"invalid argument: validation failed", 42 | "details":{"query:deep_object_filter":["#/baz: length must be \u003e= 3, but got 2"]} 43 | }`, 44 | }, 45 | { 46 | name: "validation_failed_json", 47 | url: `/query-object?in_query[1]=0&in_query[2]=0&in_query[3]=0&json_filter={"foo":"string"}&deep_object_filter[bar]=asd`, 48 | code: http.StatusBadRequest, 49 | resp: `{ 50 | "msg":"invalid argument: validation failed", 51 | "details":{"query:json_filter":["#/foo: length must be \u003c= 5, but got 6"]} 52 | }`, 53 | }, 54 | { 55 | name: "ok", 56 | url: `/query-object?in_query[1]=0&in_query[2]=0&in_query[3]=0&json_map={"123":123.45}&json_filter={"foo":"strin"}&deep_object_filter[bar]=asd`, 57 | code: http.StatusOK, 58 | resp: `{ 59 | "inQuery":{"1":0,"2":0,"3":0},"jsonMap":{"123":123.45},"jsonFilter":{"foo":"strin"}, 60 | "deepObjectFilter":{"bar":"asd"} 61 | }`, 62 | }, 63 | } { 64 | t.Run(tc.name, func(t *testing.T) { 65 | req, err := http.NewRequest( 66 | http.MethodGet, 67 | srv.URL+tc.url, 68 | nil, 69 | ) 70 | require.NoError(t, err) 71 | 72 | resp, err := http.DefaultTransport.RoundTrip(req) 73 | require.NoError(t, err) 74 | 75 | body, err := io.ReadAll(resp.Body) 76 | assert.NoError(t, err) 77 | assert.NoError(t, resp.Body.Close()) 78 | assertjson.EqMarshal(t, tc.resp, json.RawMessage(body)) 79 | assert.Equal(t, tc.code, resp.StatusCode) 80 | }) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /_examples/advanced-generic-openapi31/raw_body.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/swaggest/usecase" 7 | ) 8 | 9 | func rawBody() usecase.Interactor { 10 | type rawBody struct { 11 | TextBody string `contentType:"text/plain"` 12 | CSVBody string `contentType:"text/csv"` 13 | } 14 | 15 | u := usecase.NewInteractor(func(ctx context.Context, input rawBody, output *rawBody) error { 16 | *output = input 17 | 18 | return nil 19 | }) 20 | 21 | u.SetTitle("Request/response With Raw Body") 22 | u.SetDescription("The `contentType` tag acts as a discriminator of where to read/write the body.") 23 | u.SetTags("Request", "Response") 24 | 25 | return u 26 | } 27 | -------------------------------------------------------------------------------- /_examples/advanced-generic-openapi31/request_response_mapping.go: -------------------------------------------------------------------------------- 1 | //go:build go1.18 2 | 3 | package main 4 | 5 | import ( 6 | "context" 7 | 8 | "github.com/swaggest/usecase" 9 | ) 10 | 11 | func reqRespMapping() usecase.Interactor { 12 | type inputPort struct { 13 | Val1 string `description:"Simple scalar value with sample validation." required:"true" minLength:"3"` 14 | Val2 int `description:"Simple scalar value with sample validation." required:"true" minimum:"3"` 15 | } 16 | 17 | type outputPort struct { 18 | Val1 string `json:"-" description:"Simple scalar value with sample validation." required:"true" minLength:"3"` 19 | Val2 int `json:"-" description:"Simple scalar value with sample validation." required:"true" minimum:"3"` 20 | } 21 | 22 | u := usecase.NewInteractor(func(ctx context.Context, in inputPort, out *outputPort) (err error) { 23 | out.Val1 = in.Val1 24 | out.Val2 = in.Val2 25 | 26 | return nil 27 | }) 28 | 29 | u.SetTitle("Request Response Mapping") 30 | u.SetName("reqRespMapping") 31 | u.SetDescription("This use case has transport concerns fully decoupled with external req/resp mapping.") 32 | u.SetTags("Request", "Response") 33 | 34 | return u 35 | } 36 | -------------------------------------------------------------------------------- /_examples/advanced-generic-openapi31/request_response_mapping_test.go: -------------------------------------------------------------------------------- 1 | //go:build go1.18 2 | 3 | package main 4 | 5 | import ( 6 | "bytes" 7 | "io/ioutil" 8 | "net/http" 9 | "net/http/httptest" 10 | "testing" 11 | 12 | "github.com/bool64/httptestbench" 13 | "github.com/stretchr/testify/assert" 14 | "github.com/stretchr/testify/require" 15 | "github.com/valyala/fasthttp" 16 | ) 17 | 18 | func Test_requestResponseMapping(t *testing.T) { 19 | r := NewRouter() 20 | 21 | srv := httptest.NewServer(r) 22 | defer srv.Close() 23 | 24 | req, err := http.NewRequest(http.MethodPost, srv.URL+"/req-resp-mapping", 25 | bytes.NewReader([]byte(`val2=3`))) 26 | require.NoError(t, err) 27 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 28 | req.Header.Set("X-Header", "abc") 29 | 30 | resp, err := http.DefaultTransport.RoundTrip(req) 31 | require.NoError(t, err) 32 | 33 | assert.Equal(t, http.StatusNoContent, resp.StatusCode) 34 | 35 | body, err := ioutil.ReadAll(resp.Body) 36 | assert.NoError(t, err) 37 | assert.NoError(t, resp.Body.Close()) 38 | assert.Equal(t, "", string(body)) 39 | 40 | assert.Equal(t, "abc", resp.Header.Get("X-Value-1")) 41 | assert.Equal(t, "3", resp.Header.Get("X-Value-2")) 42 | } 43 | 44 | func Benchmark_requestResponseMapping(b *testing.B) { 45 | r := NewRouter() 46 | 47 | srv := httptest.NewServer(r) 48 | defer srv.Close() 49 | 50 | httptestbench.RoundTrip(b, 50, func(i int, req *fasthttp.Request) { 51 | req.Header.SetMethod(http.MethodPost) 52 | req.SetRequestURI(srv.URL + "/req-resp-mapping") 53 | req.Header.Set("X-Header", "abc") 54 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 55 | req.SetBody([]byte(`val2=3`)) 56 | }, func(i int, resp *fasthttp.Response) bool { 57 | return resp.StatusCode() == http.StatusNoContent 58 | }) 59 | } 60 | -------------------------------------------------------------------------------- /_examples/advanced-generic-openapi31/request_text_body.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net/http" 7 | 8 | "github.com/swaggest/usecase" 9 | ) 10 | 11 | type textReqBodyInput struct { 12 | Path string `path:"path"` 13 | Query int `query:"query"` 14 | text []byte 15 | err error 16 | } 17 | 18 | func (c *textReqBodyInput) SetRequest(r *http.Request) { 19 | c.text, c.err = io.ReadAll(r.Body) 20 | clErr := r.Body.Close() 21 | 22 | if c.err == nil { 23 | c.err = clErr 24 | } 25 | } 26 | 27 | func textReqBody() usecase.Interactor { 28 | type output struct { 29 | Path string `json:"path"` 30 | Query int `json:"query"` 31 | Text string `json:"text"` 32 | } 33 | 34 | u := usecase.NewInteractor(func(ctx context.Context, in textReqBodyInput, out *output) (err error) { 35 | out.Text = string(in.text) 36 | out.Path = in.Path 37 | out.Query = in.Query 38 | 39 | return nil 40 | }) 41 | 42 | u.SetTitle("Request With Text Body") 43 | u.SetDescription("This usecase allows direct access to original `*http.Request` while keeping automated decoding of parameters.") 44 | u.SetTags("Request") 45 | 46 | return u 47 | } 48 | 49 | func textReqBodyPtr() usecase.Interactor { 50 | type output struct { 51 | Path string `json:"path"` 52 | Query int `json:"query"` 53 | Text string `json:"text"` 54 | } 55 | 56 | u := usecase.NewInteractor(func(ctx context.Context, in *textReqBodyInput, out *output) (err error) { 57 | out.Text = string(in.text) 58 | out.Path = in.Path 59 | out.Query = in.Query 60 | 61 | return nil 62 | }) 63 | 64 | u.SetTitle("Request With Text Body (ptr input)") 65 | u.SetDescription("This usecase allows direct access to original `*http.Request` while keeping automated decoding of parameters.") 66 | u.SetTags("Request") 67 | 68 | return u 69 | } 70 | -------------------------------------------------------------------------------- /_examples/advanced-generic-openapi31/router_test.go: -------------------------------------------------------------------------------- 1 | //go:build go1.18 2 | 3 | package main 4 | 5 | import ( 6 | "encoding/json" 7 | "net/http" 8 | "net/http/httptest" 9 | "os" 10 | "testing" 11 | 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/require" 14 | "github.com/swaggest/assertjson" 15 | ) 16 | 17 | func TestNewRouter(t *testing.T) { 18 | r := NewRouter() 19 | 20 | req, err := http.NewRequest(http.MethodGet, "/docs/openapi.json", nil) 21 | require.NoError(t, err) 22 | 23 | rw := httptest.NewRecorder() 24 | 25 | r.ServeHTTP(rw, req) 26 | assert.Equal(t, http.StatusOK, rw.Code) 27 | 28 | actualSchema, err := assertjson.MarshalIndentCompact(json.RawMessage(rw.Body.Bytes()), "", " ", 120) 29 | require.NoError(t, err) 30 | 31 | expectedSchema, err := os.ReadFile("_testdata/openapi.json") 32 | require.NoError(t, err) 33 | 34 | if !assertjson.Equal(t, expectedSchema, rw.Body.Bytes(), string(actualSchema)) { 35 | require.NoError(t, os.WriteFile("_testdata/openapi_last_run.json", actualSchema, 0o600)) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /_examples/advanced-generic-openapi31/validation.go: -------------------------------------------------------------------------------- 1 | //go:build go1.18 2 | 3 | package main 4 | 5 | import ( 6 | "context" 7 | 8 | "github.com/swaggest/usecase" 9 | ) 10 | 11 | func validation() usecase.Interactor { 12 | type inputPort struct { 13 | Header int `header:"X-Input" minimum:"10" description:"Request minimum: 10, response maximum: 20."` 14 | Query bool `query:"q" description:"This parameter will bypass explicit validation as it does not have constraints."` 15 | Data struct { 16 | Value string `json:"value" minLength:"3" description:"Request minLength: 3, response maxLength: 7"` 17 | } `json:"data" required:"true"` 18 | } 19 | 20 | type outputPort struct { 21 | Header int `header:"X-Output" json:"-" maximum:"20"` 22 | AnotherHeader bool `header:"X-Query" json:"-" description:"This header bypasses validation as it does not have constraints."` 23 | Data struct { 24 | Value string `json:"value" maxLength:"7"` 25 | } `json:"data" required:"true"` 26 | } 27 | 28 | u := usecase.NewInteractor(func(ctx context.Context, in inputPort, out *outputPort) (err error) { 29 | out.Header = in.Header 30 | out.AnotherHeader = in.Query 31 | out.Data.Value = in.Data.Value 32 | 33 | return nil 34 | }) 35 | 36 | u.SetTitle("Validation") 37 | u.SetDescription("Input/Output with validation.") 38 | u.SetTags("Request", "Response", "Validation") 39 | 40 | return u 41 | } 42 | -------------------------------------------------------------------------------- /_examples/advanced-generic-openapi31/validation_test.go: -------------------------------------------------------------------------------- 1 | //go:build go1.18 2 | 3 | package main 4 | 5 | import ( 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | "github.com/bool64/httptestbench" 11 | "github.com/valyala/fasthttp" 12 | ) 13 | 14 | // Benchmark_validation-4 18979 53012 ns/op 197 B:rcvd/op 170 B:sent/op 18861 rps 14817 B/op 131 allocs/op. 15 | // Benchmark_validation-4 17665 58243 ns/op 177 B:rcvd/op 170 B:sent/op 17161 rps 16349 B/op 132 allocs/op. 16 | func Benchmark_validation(b *testing.B) { 17 | r := NewRouter() 18 | 19 | srv := httptest.NewServer(r) 20 | defer srv.Close() 21 | 22 | httptestbench.RoundTrip(b, 50, func(i int, req *fasthttp.Request) { 23 | req.Header.SetMethod(http.MethodPost) 24 | req.SetRequestURI(srv.URL + "/validation?q=true") 25 | req.Header.Set("X-Input", "12") 26 | req.Header.Set("Content-Type", "application/json") 27 | req.SetBody([]byte(`{"data":{"value":"abc"}}`)) 28 | }, func(i int, resp *fasthttp.Response) bool { 29 | return resp.StatusCode() == http.StatusOK 30 | }) 31 | } 32 | 33 | func Benchmark_noValidation(b *testing.B) { 34 | r := NewRouter() 35 | 36 | srv := httptest.NewServer(r) 37 | defer srv.Close() 38 | 39 | httptestbench.RoundTrip(b, 50, func(i int, req *fasthttp.Request) { 40 | req.Header.SetMethod(http.MethodPost) 41 | req.SetRequestURI(srv.URL + "/no-validation?q=true") 42 | req.Header.Set("X-Input", "12") 43 | req.Header.Set("Content-Type", "application/json") 44 | req.SetBody([]byte(`{"data":{"value":"abc"}}`)) 45 | }, func(i int, resp *fasthttp.Response) bool { 46 | return resp.StatusCode() == http.StatusOK 47 | }) 48 | } 49 | -------------------------------------------------------------------------------- /_examples/advanced/dummy.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/swaggest/usecase" 7 | ) 8 | 9 | func dummy() usecase.Interactor { 10 | return usecase.NewIOI(nil, nil, func(ctx context.Context, input, output interface{}) error { 11 | return nil 12 | }) 13 | } 14 | -------------------------------------------------------------------------------- /_examples/advanced/dynamic_schema.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "net/http" 8 | 9 | "github.com/bool64/ctxd" 10 | "github.com/swaggest/jsonschema-go" 11 | "github.com/swaggest/rest/request" 12 | "github.com/swaggest/usecase" 13 | "github.com/swaggest/usecase/status" 14 | ) 15 | 16 | type dynamicInput struct { 17 | jsonschema.Struct 18 | request.EmbeddedSetter 19 | 20 | // Type is a static field example. 21 | Type string `query:"type"` 22 | } 23 | 24 | type dynamicOutput struct { 25 | // Embedded jsonschema.Struct exposes dynamic fields for documentation. 26 | jsonschema.Struct 27 | 28 | jsonFields map[string]interface{} 29 | headerFields map[string]string 30 | 31 | // Status is a static field example. 32 | Status string `json:"status"` 33 | } 34 | 35 | func (o dynamicOutput) SetupResponseHeader(h http.Header) { 36 | for k, v := range o.headerFields { 37 | h.Set(k, v) 38 | } 39 | } 40 | 41 | func (o dynamicOutput) MarshalJSON() ([]byte, error) { 42 | if o.jsonFields == nil { 43 | o.jsonFields = map[string]interface{}{} 44 | } 45 | 46 | o.jsonFields["status"] = o.Status 47 | 48 | return json.Marshal(o.jsonFields) 49 | } 50 | 51 | func dynamicSchema() usecase.Interactor { 52 | dynIn := dynamicInput{} 53 | dynIn.DefName = "DynIn123" 54 | dynIn.Struct.Fields = []jsonschema.Field{ 55 | {Name: "Foo", Value: 123, Tag: `header:"foo" enum:"123,456,789"`}, 56 | {Name: "Bar", Value: "abc", Tag: `query:"bar"`}, 57 | } 58 | 59 | dynOut := dynamicOutput{} 60 | dynOut.DefName = "DynOut123" 61 | dynOut.Struct.Fields = []jsonschema.Field{ 62 | {Name: "Foo", Value: 123, Tag: `header:"foo" enum:"123,456,789"`}, 63 | {Name: "Bar", Value: "abc", Tag: `json:"bar"`}, 64 | } 65 | 66 | u := usecase.NewIOI(dynIn, dynOut, func(ctx context.Context, input, output interface{}) (err error) { 67 | var ( 68 | in = input.(dynamicInput) 69 | out = output.(*dynamicOutput) 70 | ) 71 | 72 | switch in.Type { 73 | case "ok": 74 | out.Status = "ok" 75 | out.jsonFields = map[string]interface{}{ 76 | "bar": in.Request().URL.Query().Get("bar"), 77 | } 78 | out.headerFields = map[string]string{ 79 | "foo": in.Request().Header.Get("foo"), 80 | } 81 | case "invalid_argument": 82 | return status.Wrap(errors.New("bad value for foo"), status.InvalidArgument) 83 | case "conflict": 84 | return status.Wrap(ctxd.NewError(ctx, "conflict", "foo", "bar"), 85 | status.AlreadyExists) 86 | } 87 | 88 | return nil 89 | }) 90 | 91 | u.SetTitle("Dynamic Request Schema") 92 | u.SetDescription("This use case demonstrates documentation of types that are only known at runtime.") 93 | u.SetExpectedErrors(status.InvalidArgument, status.FailedPrecondition, status.AlreadyExists) 94 | 95 | return u 96 | } 97 | -------------------------------------------------------------------------------- /_examples/advanced/dynamic_schema_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/swaggest/assertjson" 10 | ) 11 | 12 | func Test_dynamicOutput(t *testing.T) { 13 | r := NewRouter() 14 | 15 | req := httptest.NewRequest(http.MethodGet, "/dynamic-schema?bar=ccc&type=ok", nil) 16 | req.Header.Set("foo", "456") 17 | 18 | rw := httptest.NewRecorder() 19 | r.ServeHTTP(rw, req) 20 | 21 | assertjson.Equal(t, []byte(`{"bar": "ccc","status": "ok"}`), rw.Body.Bytes()) 22 | assert.Equal(t, http.StatusOK, rw.Code) 23 | assert.Equal(t, "456", rw.Header().Get("foo")) 24 | } 25 | -------------------------------------------------------------------------------- /_examples/advanced/error_response.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/bool64/ctxd" 8 | "github.com/swaggest/usecase" 9 | "github.com/swaggest/usecase/status" 10 | ) 11 | 12 | type customErr struct { 13 | Message string `json:"msg"` 14 | Details map[string]interface{} `json:"details,omitempty"` 15 | } 16 | 17 | func errorResponse() usecase.Interactor { 18 | type errType struct { 19 | Type string `query:"type" enum:"ok,invalid_argument,conflict" required:"true"` 20 | } 21 | 22 | type okResp struct { 23 | Status string `json:"status"` 24 | } 25 | 26 | u := usecase.NewIOI(new(errType), new(okResp), func(ctx context.Context, input, output interface{}) (err error) { 27 | var ( 28 | in = input.(*errType) 29 | out = output.(*okResp) 30 | ) 31 | 32 | switch in.Type { 33 | case "ok": 34 | out.Status = "ok" 35 | case "invalid_argument": 36 | return status.Wrap(errors.New("bad value for foo"), status.InvalidArgument) 37 | case "conflict": 38 | return status.Wrap(ctxd.NewError(ctx, "conflict", "foo", "bar"), 39 | status.AlreadyExists) 40 | } 41 | 42 | return nil 43 | }) 44 | 45 | u.SetTitle("Declare Expected Errors") 46 | u.SetDescription("This use case demonstrates documentation of expected errors.") 47 | u.SetExpectedErrors(status.InvalidArgument, anotherErr{}, status.FailedPrecondition, status.AlreadyExists) 48 | 49 | return u 50 | } 51 | 52 | // anotherErr is another custom error. 53 | type anotherErr struct { 54 | Foo int `json:"foo"` 55 | } 56 | 57 | func (anotherErr) Error() string { 58 | return "foo happened" 59 | } 60 | -------------------------------------------------------------------------------- /_examples/advanced/file_multi_upload.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "mime/multipart" 6 | "net/textproto" 7 | 8 | "github.com/swaggest/usecase" 9 | ) 10 | 11 | func fileMultiUploader() usecase.Interactor { 12 | type upload struct { 13 | Simple string `formData:"simple" description:"Simple scalar value in body."` 14 | Query int `query:"in_query" description:"Simple scalar value in query."` 15 | Uploads1 []*multipart.FileHeader `formData:"uploads1" description:"Uploads with *multipart.FileHeader."` 16 | Uploads2 []multipart.File `formData:"uploads2" description:"Uploads with multipart.File."` 17 | } 18 | 19 | type info struct { 20 | Filenames []string `json:"filenames"` 21 | Headers []textproto.MIMEHeader `json:"headers"` 22 | Sizes []int64 `json:"sizes"` 23 | Upload1Peeks []string `json:"peeks1"` 24 | Upload2Peeks []string `json:"peeks2"` 25 | Simple string `json:"simple"` 26 | Query int `json:"inQuery"` 27 | } 28 | 29 | u := usecase.NewIOI(new(upload), new(info), func(ctx context.Context, input, output interface{}) (err error) { 30 | var ( 31 | in = input.(*upload) 32 | out = output.(*info) 33 | ) 34 | 35 | out.Query = in.Query 36 | out.Simple = in.Simple 37 | for _, o := range in.Uploads1 { 38 | out.Filenames = append(out.Filenames, o.Filename) 39 | out.Headers = append(out.Headers, o.Header) 40 | out.Sizes = append(out.Sizes, o.Size) 41 | 42 | f, err := o.Open() 43 | if err != nil { 44 | return err 45 | } 46 | p := make([]byte, 100) 47 | _, err = f.Read(p) 48 | if err != nil { 49 | return err 50 | } 51 | 52 | out.Upload1Peeks = append(out.Upload1Peeks, string(p)) 53 | 54 | err = f.Close() 55 | if err != nil { 56 | return err 57 | } 58 | } 59 | 60 | for _, o := range in.Uploads2 { 61 | p := make([]byte, 100) 62 | _, err = o.Read(p) 63 | if err != nil { 64 | return err 65 | } 66 | 67 | out.Upload2Peeks = append(out.Upload2Peeks, string(p)) 68 | err = o.Close() 69 | if err != nil { 70 | return err 71 | } 72 | } 73 | 74 | return nil 75 | }) 76 | 77 | u.SetTitle("Files Uploads With 'multipart/form-data'") 78 | 79 | return u 80 | } 81 | -------------------------------------------------------------------------------- /_examples/advanced/file_upload.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "mime/multipart" 6 | "net/textproto" 7 | 8 | "github.com/swaggest/usecase" 9 | ) 10 | 11 | func fileUploader() usecase.Interactor { 12 | type upload struct { 13 | Simple string `formData:"simple" description:"Simple scalar value in body."` 14 | Query int `query:"in_query" description:"Simple scalar value in query."` 15 | Upload1 *multipart.FileHeader `formData:"upload1" description:"Upload with *multipart.FileHeader."` 16 | Upload2 multipart.File `formData:"upload2" description:"Upload with multipart.File."` 17 | } 18 | 19 | type info struct { 20 | Filename string `json:"filename"` 21 | Header textproto.MIMEHeader `json:"header"` 22 | Size int64 `json:"size"` 23 | Upload1Peek string `json:"peek1"` 24 | Upload2Peek string `json:"peek2"` 25 | Simple string `json:"simple"` 26 | Query int `json:"inQuery"` 27 | } 28 | 29 | u := usecase.NewIOI(new(upload), new(info), func(ctx context.Context, input, output interface{}) (err error) { 30 | var ( 31 | in = input.(*upload) 32 | out = output.(*info) 33 | ) 34 | 35 | out.Query = in.Query 36 | out.Simple = in.Simple 37 | out.Filename = in.Upload1.Filename 38 | out.Header = in.Upload1.Header 39 | out.Size = in.Upload1.Size 40 | 41 | f, err := in.Upload1.Open() 42 | if err != nil { 43 | return err 44 | } 45 | 46 | defer func() { 47 | clErr := f.Close() 48 | if clErr != nil && err == nil { 49 | err = clErr 50 | } 51 | 52 | clErr = in.Upload2.Close() 53 | if clErr != nil && err == nil { 54 | err = clErr 55 | } 56 | }() 57 | 58 | p := make([]byte, 100) 59 | _, err = f.Read(p) 60 | if err != nil { 61 | return err 62 | } 63 | 64 | out.Upload1Peek = string(p) 65 | 66 | p = make([]byte, 100) 67 | _, err = in.Upload2.Read(p) 68 | if err != nil { 69 | return err 70 | } 71 | 72 | out.Upload2Peek = string(p) 73 | 74 | return nil 75 | }) 76 | 77 | u.SetTitle("File Upload With 'multipart/form-data'") 78 | 79 | return u 80 | } 81 | -------------------------------------------------------------------------------- /_examples/advanced/gzip_pass_through.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/swaggest/rest/gzip" 7 | "github.com/swaggest/usecase" 8 | ) 9 | 10 | type gzipPassThroughInput struct { 11 | PlainStruct bool `query:"plainStruct" description:"Output plain structure instead of gzip container."` 12 | CountItems bool `query:"countItems" description:"Invokes internal decoding of compressed data."` 13 | } 14 | 15 | // gzipPassThroughOutput defers data to an accessor function instead of using struct directly. 16 | // This is necessary to allow containers that can data in binary wire-friendly format. 17 | type gzipPassThroughOutput interface { 18 | // Data should be accessed though an accessor to allow container interface. 19 | gzipPassThroughStruct() gzipPassThroughStruct 20 | } 21 | 22 | // gzipPassThroughStruct represents the actual structure that is held in the container 23 | // and implements gzipPassThroughOutput to be directly useful in output. 24 | type gzipPassThroughStruct struct { 25 | Header string `header:"X-Header" json:"-"` 26 | ID int `json:"id"` 27 | Text []string `json:"text"` 28 | } 29 | 30 | func (d gzipPassThroughStruct) gzipPassThroughStruct() gzipPassThroughStruct { 31 | return d 32 | } 33 | 34 | // gzipPassThroughContainer is wrapping gzip.JSONContainer and implements gzipPassThroughOutput. 35 | type gzipPassThroughContainer struct { 36 | Header string `header:"X-Header" json:"-"` 37 | gzip.JSONContainer 38 | } 39 | 40 | func (dc gzipPassThroughContainer) gzipPassThroughStruct() gzipPassThroughStruct { 41 | var p gzipPassThroughStruct 42 | 43 | err := dc.UnpackJSON(&p) 44 | if err != nil { 45 | panic(err) 46 | } 47 | 48 | return p 49 | } 50 | 51 | func directGzip() usecase.Interactor { 52 | // Prepare moderately big JSON, resulting JSON payload is ~67KB. 53 | rawData := gzipPassThroughStruct{ 54 | ID: 123, 55 | } 56 | for i := 0; i < 400; i++ { 57 | rawData.Text = append(rawData.Text, "Quis autem vel eum iure reprehenderit, qui in ea voluptate velit esse, "+ 58 | "quam nihil molestiae consequatur, vel illum, qui dolorem eum fugiat, quo voluptas nulla pariatur?") 59 | } 60 | 61 | // Precompute compressed data container. Generally this step should be owned by a caching storage of data. 62 | dataFromCache := gzipPassThroughContainer{} 63 | 64 | err := dataFromCache.PackJSON(rawData) 65 | if err != nil { 66 | panic(err) 67 | } 68 | 69 | u := usecase.NewIOI(new(gzipPassThroughInput), new(gzipPassThroughOutput), 70 | func(ctx context.Context, input, output interface{}) error { 71 | var ( 72 | in = input.(*gzipPassThroughInput) 73 | out = output.(*gzipPassThroughOutput) 74 | ) 75 | 76 | if in.PlainStruct { 77 | o := rawData 78 | o.Header = "cba" 79 | *out = o 80 | } else { 81 | o := dataFromCache 82 | o.Header = "abc" 83 | *out = o 84 | } 85 | 86 | // Imitating an internal read operation on data in container. 87 | if in.CountItems { 88 | _ = len((*out).gzipPassThroughStruct().Text) 89 | } 90 | 91 | return nil 92 | }) 93 | 94 | return u 95 | } 96 | -------------------------------------------------------------------------------- /_examples/advanced/json_body.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/swaggest/jsonschema-go" 7 | "github.com/swaggest/usecase" 8 | ) 9 | 10 | func jsonBody() usecase.Interactor { 11 | type JSONPayload struct { 12 | ID int `json:"id"` 13 | Name string `json:"name"` 14 | } 15 | 16 | type inputWithJSON struct { 17 | Header string `header:"X-Header" description:"Simple scalar value in header."` 18 | Query jsonschema.Date `query:"in_query" description:"Simple scalar value in query."` 19 | Path string `path:"in-path" description:"Simple scalar value in path"` 20 | NamedStruct JSONPayload `json:"namedStruct" deprecated:"true"` 21 | JSONPayload 22 | } 23 | 24 | type outputWithJSON struct { 25 | Header string `json:"inHeader"` 26 | Query jsonschema.Date `json:"inQuery" deprecated:"true"` 27 | Path string `json:"inPath"` 28 | JSONPayload 29 | } 30 | 31 | u := usecase.NewIOI(new(inputWithJSON), new(outputWithJSON), 32 | func(ctx context.Context, input, output interface{}) (err error) { 33 | var ( 34 | in = input.(*inputWithJSON) 35 | out = output.(*outputWithJSON) 36 | ) 37 | 38 | out.Query = in.Query 39 | out.Header = in.Header 40 | out.Path = in.Path 41 | out.JSONPayload = in.JSONPayload 42 | 43 | return nil 44 | }) 45 | 46 | u.SetTitle("Request With JSON Body") 47 | u.SetDescription("Request with JSON body and query/header/path params, response with JSON body and data from request.") 48 | 49 | return u 50 | } 51 | -------------------------------------------------------------------------------- /_examples/advanced/json_body_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/bool64/httptestbench" 9 | "github.com/valyala/fasthttp" 10 | ) 11 | 12 | // Benchmark_jsonBody-4 29671 37417 ns/op 194 B:rcvd/op 181 B:sent/op 26705 rps 6068 B/op 58 allocs/op. 13 | // Benchmark_jsonBody-4 29749 35934 ns/op 194 B:rcvd/op 181 B:sent/op 27829 rps 6063 B/op 57 allocs/op. 14 | func Benchmark_jsonBody(b *testing.B) { 15 | r := NewRouter() 16 | 17 | srv := httptest.NewServer(r) 18 | defer srv.Close() 19 | 20 | httptestbench.RoundTrip(b, 50, func(i int, req *fasthttp.Request) { 21 | req.Header.SetMethod(http.MethodPost) 22 | req.SetRequestURI(srv.URL + "/json-body/abc?in_query=2006-01-02") 23 | req.Header.Set("Content-Type", "application/json") 24 | req.Header.Set("X-Header", "def") 25 | req.SetBody([]byte(`{"id":321,"name":"Jane"}`)) 26 | }, func(i int, resp *fasthttp.Response) bool { 27 | return resp.StatusCode() == http.StatusCreated 28 | }) 29 | } 30 | -------------------------------------------------------------------------------- /_examples/advanced/json_body_validation.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/swaggest/usecase" 7 | ) 8 | 9 | func jsonBodyValidation() usecase.Interactor { 10 | u := usecase.IOInteractor{} 11 | 12 | u.SetTitle("Request With JSON Body and non-trivial validation") 13 | u.SetDescription("Request with JSON body and query/header/path params, response with JSON body and data from request.") 14 | 15 | type JSONPayload struct { 16 | ID int `json:"id" minimum:"100"` 17 | Name string `json:"name" minLength:"3"` 18 | } 19 | 20 | type inputWithJSON struct { 21 | Header string `header:"X-Header" description:"Simple scalar value in header." minLength:"3"` 22 | Query int `query:"in_query" description:"Simple scalar value in query." minimum:"100"` 23 | Path string `path:"in-path" description:"Simple scalar value in path" minLength:"3"` 24 | JSONPayload 25 | } 26 | 27 | type outputWithJSON struct { 28 | Header string `json:"inHeader" minLength:"3"` 29 | Query int `json:"inQuery" minimum:"3"` 30 | Path string `json:"inPath" minLength:"3"` 31 | JSONPayload 32 | } 33 | 34 | u.Input = new(inputWithJSON) 35 | u.Output = new(outputWithJSON) 36 | 37 | u.Interactor = usecase.Interact(func(ctx context.Context, input, output interface{}) (err error) { 38 | var ( 39 | in = input.(*inputWithJSON) 40 | out = output.(*outputWithJSON) 41 | ) 42 | 43 | out.Query = in.Query 44 | out.Header = in.Header 45 | out.Path = in.Path 46 | out.JSONPayload = in.JSONPayload 47 | 48 | return nil 49 | }) 50 | 51 | return u 52 | } 53 | -------------------------------------------------------------------------------- /_examples/advanced/json_body_validation_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/bool64/httptestbench" 9 | "github.com/valyala/fasthttp" 10 | ) 11 | 12 | // Benchmark_jsonBodyValidation-4 19126 60170 ns/op 194 B:rcvd/op 192 B:sent/op 16620 rps 18363 B/op 144 allocs/op. 13 | func Benchmark_jsonBodyValidation(b *testing.B) { 14 | r := NewRouter() 15 | 16 | srv := httptest.NewServer(r) 17 | defer srv.Close() 18 | 19 | httptestbench.RoundTrip(b, 50, func(i int, req *fasthttp.Request) { 20 | req.Header.SetMethod(http.MethodPost) 21 | req.SetRequestURI(srv.URL + "/json-body-validation/abc?in_query=123") 22 | req.Header.Set("Content-Type", "application/json") 23 | req.Header.Set("X-Header", "def") 24 | req.SetBody([]byte(`{"id":321,"name":"Jane"}`)) 25 | }, func(i int, resp *fasthttp.Response) bool { 26 | return resp.StatusCode() == http.StatusOK 27 | }) 28 | } 29 | -------------------------------------------------------------------------------- /_examples/advanced/json_map_body.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | 7 | "github.com/swaggest/usecase" 8 | ) 9 | 10 | type JSONMapPayload map[string]float64 11 | 12 | type jsonMapReq struct { 13 | Header string `header:"X-Header" description:"Simple scalar value in header."` 14 | Query int `query:"in_query" description:"Simple scalar value in query."` 15 | JSONMapPayload 16 | } 17 | 18 | func (j *jsonMapReq) UnmarshalJSON(data []byte) error { 19 | return json.Unmarshal(data, &j.JSONMapPayload) 20 | } 21 | 22 | func jsonMapBody() usecase.Interactor { 23 | type jsonOutput struct { 24 | Header string `json:"inHeader"` 25 | Query int `json:"inQuery"` 26 | Data JSONMapPayload `json:"data"` 27 | } 28 | 29 | u := usecase.NewIOI(new(jsonMapReq), new(jsonOutput), func(ctx context.Context, input, output interface{}) (err error) { 30 | var ( 31 | in = input.(*jsonMapReq) 32 | out = output.(*jsonOutput) 33 | ) 34 | 35 | out.Query = in.Query 36 | out.Header = in.Header 37 | out.Data = in.JSONMapPayload 38 | 39 | return nil 40 | }) 41 | 42 | u.SetTitle("Request With JSON Map In Body") 43 | 44 | return u 45 | } 46 | -------------------------------------------------------------------------------- /_examples/advanced/json_param.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/google/uuid" 7 | "github.com/swaggest/usecase" 8 | ) 9 | 10 | func jsonParam() usecase.Interactor { 11 | type JSONPayload struct { 12 | ID int `json:"id"` 13 | Name string `json:"name"` 14 | } 15 | 16 | type inputWithJSON struct { 17 | Header string `header:"X-Header" description:"Simple scalar value in header."` 18 | Query int `query:"in_query" description:"Simple scalar value in query."` 19 | Path string `path:"in-path" description:"Simple scalar value in path"` 20 | Cookie uuid.UUID `cookie:"in_cookie" description:"UUID in cookie."` 21 | Identity JSONPayload `query:"identity" description:"JSON value in query"` 22 | } 23 | 24 | type outputWithJSON struct { 25 | Header string `json:"inHeader"` 26 | Query int `json:"inQuery"` 27 | Path string `json:"inPath"` 28 | JSONPayload 29 | } 30 | 31 | u := usecase.NewIOI(new(inputWithJSON), new(outputWithJSON), func(ctx context.Context, input, output interface{}) (err error) { 32 | var ( 33 | in = input.(*inputWithJSON) 34 | out = output.(*outputWithJSON) 35 | ) 36 | 37 | out.Query = in.Query 38 | out.Header = in.Header 39 | out.Path = in.Path 40 | out.JSONPayload = in.Identity 41 | 42 | return nil 43 | }) 44 | 45 | u.SetTitle("Request With JSON Query Parameter") 46 | u.SetDescription("Request with JSON body and query/header/path params, response with JSON body and data from request.") 47 | 48 | return u 49 | } 50 | -------------------------------------------------------------------------------- /_examples/advanced/json_slice_body.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | 7 | "github.com/swaggest/usecase" 8 | ) 9 | 10 | type JSONSlicePayload []int 11 | 12 | type jsonSliceReq struct { 13 | Header string `header:"X-Header" description:"Simple scalar value in header."` 14 | Query int `query:"in_query" description:"Simple scalar value in query."` 15 | JSONSlicePayload 16 | } 17 | 18 | func (j *jsonSliceReq) UnmarshalJSON(data []byte) error { 19 | return json.Unmarshal(data, &j.JSONSlicePayload) 20 | } 21 | 22 | func jsonSliceBody() usecase.Interactor { 23 | type jsonOutput struct { 24 | Header string `json:"inHeader"` 25 | Query int `json:"inQuery"` 26 | Data JSONSlicePayload `json:"data"` 27 | } 28 | 29 | u := usecase.NewIOI(new(jsonSliceReq), new(jsonOutput), func(ctx context.Context, input, output interface{}) (err error) { 30 | var ( 31 | in = input.(*jsonSliceReq) 32 | out = output.(*jsonOutput) 33 | ) 34 | 35 | out.Query = in.Query 36 | out.Header = in.Header 37 | out.Data = in.JSONSlicePayload 38 | 39 | return nil 40 | }) 41 | 42 | u.SetTitle("Request With JSON Array In Body") 43 | 44 | return u 45 | } 46 | -------------------------------------------------------------------------------- /_examples/advanced/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | ) 7 | 8 | func main() { 9 | log.Println("http://localhost:8011/docs") 10 | if err := http.ListenAndServe("localhost:8011", NewRouter()); err != nil { 11 | log.Fatal(err) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /_examples/advanced/no_validation.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/swaggest/usecase" 7 | ) 8 | 9 | func noValidation() usecase.Interactor { 10 | type inputPort struct { 11 | Header int `header:"X-Input"` 12 | Query bool `query:"q"` 13 | Data struct { 14 | Value string `json:"value"` 15 | } `json:"data"` 16 | } 17 | 18 | type outputPort struct { 19 | Header int `header:"X-Output" json:"-"` 20 | AnotherHeader bool `header:"X-Query" json:"-"` 21 | Data struct { 22 | Value string `json:"value"` 23 | } `json:"data"` 24 | } 25 | 26 | u := usecase.NewIOI(new(inputPort), new(outputPort), func(ctx context.Context, input, output interface{}) (err error) { 27 | in := input.(*inputPort) 28 | out := output.(*outputPort) 29 | 30 | out.Header = in.Header 31 | out.AnotherHeader = in.Query 32 | out.Data.Value = in.Data.Value 33 | 34 | return nil 35 | }) 36 | 37 | u.SetTitle("No Validation") 38 | u.SetDescription("Input/Output without validation.") 39 | 40 | return u 41 | } 42 | -------------------------------------------------------------------------------- /_examples/advanced/output_headers.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/swaggest/usecase" 7 | ) 8 | 9 | func outputHeaders() usecase.Interactor { 10 | u := struct { 11 | usecase.Interactor 12 | usecase.Info 13 | usecase.WithOutput 14 | }{} 15 | 16 | u.SetTitle("Output With Headers") 17 | u.SetDescription("Output with headers.") 18 | 19 | type headerOutput struct { 20 | Header string `header:"X-Header" json:"-" description:"Sample response header."` 21 | InBody string `json:"inBody" deprecated:"true"` 22 | } 23 | 24 | u.Output = new(headerOutput) 25 | 26 | u.Interactor = usecase.Interact(func(ctx context.Context, input, output interface{}) (err error) { 27 | out := output.(*headerOutput) 28 | 29 | out.Header = "abc" 30 | out.InBody = "def" 31 | 32 | return nil 33 | }) 34 | 35 | return u 36 | } 37 | -------------------------------------------------------------------------------- /_examples/advanced/output_headers_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/bool64/httptestbench" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | "github.com/valyala/fasthttp" 13 | ) 14 | 15 | // Benchmark_outputHeaders-4 41424 27054 ns/op 154 B:rcvd/op 77.0 B:sent/op 36963 rps 3641 B/op 35 allocs/op. 16 | func Benchmark_outputHeaders(b *testing.B) { 17 | r := NewRouter() 18 | 19 | srv := httptest.NewServer(r) 20 | defer srv.Close() 21 | 22 | httptestbench.RoundTrip(b, 50, func(i int, req *fasthttp.Request) { 23 | req.Header.SetMethod(http.MethodGet) 24 | req.SetRequestURI(srv.URL + "/output-headers") 25 | }, func(i int, resp *fasthttp.Response) bool { 26 | return resp.StatusCode() == http.StatusOK 27 | }) 28 | } 29 | 30 | func Test_outputHeaders_HEAD(t *testing.T) { 31 | r := NewRouter() 32 | 33 | srv := httptest.NewServer(r) 34 | defer srv.Close() 35 | 36 | req, err := http.NewRequest(http.MethodHead, srv.URL+"/output-headers", nil) 37 | require.NoError(t, err) 38 | 39 | req.Header.Set("x-FoO", "40") 40 | 41 | resp, err := http.DefaultTransport.RoundTrip(req) 42 | require.NoError(t, err) 43 | 44 | assert.Equal(t, resp.StatusCode, http.StatusOK) 45 | 46 | body, err := io.ReadAll(resp.Body) 47 | assert.NoError(t, err) 48 | assert.NoError(t, resp.Body.Close()) 49 | 50 | assert.Equal(t, "abc", resp.Header.Get("X-Header")) 51 | assert.Empty(t, body) 52 | } 53 | -------------------------------------------------------------------------------- /_examples/advanced/output_writer.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/csv" 6 | 7 | "github.com/swaggest/usecase" 8 | "github.com/swaggest/usecase/status" 9 | ) 10 | 11 | func outputCSVWriter() usecase.Interactor { 12 | u := struct { 13 | usecase.Interactor 14 | usecase.Info 15 | usecase.WithOutput 16 | }{} 17 | 18 | u.SetTitle("Output With Stream Writer") 19 | u.SetDescription("Output with stream writer.") 20 | u.SetExpectedErrors(status.Internal) 21 | 22 | type writerOutput struct { 23 | Header string `header:"X-Header" description:"Sample response header."` 24 | usecase.OutputWithEmbeddedWriter 25 | } 26 | 27 | u.Output = new(writerOutput) 28 | 29 | u.Interactor = usecase.Interact(func(ctx context.Context, input, output interface{}) (err error) { 30 | out := output.(*writerOutput) 31 | 32 | out.Header = "abc" 33 | 34 | c := csv.NewWriter(out) 35 | 36 | return c.WriteAll([][]string{{"abc", "def", "hij"}, {"klm", "nop", "qrs"}}) 37 | }) 38 | 39 | return u 40 | } 41 | -------------------------------------------------------------------------------- /_examples/advanced/query_object.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/swaggest/usecase" 7 | ) 8 | 9 | func queryObject() usecase.Interactor { 10 | type inputQueryObject struct { 11 | Query map[int]float64 `query:"in_query" description:"Object value in query."` 12 | } 13 | 14 | type outputQueryObject struct { 15 | Query map[int]float64 `json:"inQuery"` 16 | } 17 | 18 | u := usecase.NewIOI(new(inputQueryObject), new(outputQueryObject), 19 | func(ctx context.Context, input, output interface{}) (err error) { 20 | var ( 21 | in = input.(*inputQueryObject) 22 | out = output.(*outputQueryObject) 23 | ) 24 | 25 | out.Query = in.Query 26 | 27 | return nil 28 | }) 29 | 30 | u.SetTitle("Request With Object As Query Parameter") 31 | 32 | return u 33 | } 34 | -------------------------------------------------------------------------------- /_examples/advanced/request_response_mapping.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/swaggest/usecase" 7 | ) 8 | 9 | func reqRespMapping() usecase.Interactor { 10 | type inputPort struct { 11 | Val1 string `description:"Simple scalar value with sample validation." required:"true" minLength:"3"` 12 | Val2 int `description:"Simple scalar value with sample validation." required:"true" minimum:"3"` 13 | } 14 | 15 | type outputPort struct { 16 | Val1 string `json:"-" description:"Simple scalar value with sample validation." required:"true" minLength:"3"` 17 | Val2 int `json:"-" description:"Simple scalar value with sample validation." required:"true" minimum:"3"` 18 | } 19 | 20 | u := usecase.NewIOI(new(inputPort), new(outputPort), func(ctx context.Context, input, output interface{}) (err error) { 21 | var ( 22 | in = input.(*inputPort) 23 | out = output.(*outputPort) 24 | ) 25 | 26 | out.Val1 = in.Val1 27 | out.Val2 = in.Val2 28 | 29 | return nil 30 | }) 31 | 32 | u.SetTitle("Request Response Mapping") 33 | u.SetName("reqRespMapping") 34 | u.SetDescription("This use case has transport concerns fully decoupled with external req/resp mapping.") 35 | 36 | return u 37 | } 38 | -------------------------------------------------------------------------------- /_examples/advanced/request_response_mapping_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "io/ioutil" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | "github.com/bool64/httptestbench" 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | "github.com/valyala/fasthttp" 14 | ) 15 | 16 | func Test_requestResponseMapping(t *testing.T) { 17 | r := NewRouter() 18 | 19 | srv := httptest.NewServer(r) 20 | defer srv.Close() 21 | 22 | req, err := http.NewRequest(http.MethodPost, srv.URL+"/req-resp-mapping", 23 | bytes.NewReader([]byte(`val2=3`))) 24 | require.NoError(t, err) 25 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 26 | req.Header.Set("X-Header", "abc") 27 | 28 | resp, err := http.DefaultTransport.RoundTrip(req) 29 | require.NoError(t, err) 30 | 31 | assert.Equal(t, http.StatusNoContent, resp.StatusCode) 32 | 33 | body, err := ioutil.ReadAll(resp.Body) 34 | assert.NoError(t, err) 35 | assert.NoError(t, resp.Body.Close()) 36 | assert.Equal(t, "", string(body)) 37 | 38 | assert.Equal(t, "abc", resp.Header.Get("X-Value-1")) 39 | assert.Equal(t, "3", resp.Header.Get("X-Value-2")) 40 | } 41 | 42 | func Benchmark_requestResponseMapping(b *testing.B) { 43 | r := NewRouter() 44 | 45 | srv := httptest.NewServer(r) 46 | defer srv.Close() 47 | 48 | httptestbench.RoundTrip(b, 50, func(i int, req *fasthttp.Request) { 49 | req.Header.SetMethod(http.MethodPost) 50 | req.SetRequestURI(srv.URL + "/req-resp-mapping") 51 | req.Header.Set("X-Header", "abc") 52 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 53 | req.SetBody([]byte(`val2=3`)) 54 | }, func(i int, resp *fasthttp.Response) bool { 55 | return resp.StatusCode() == http.StatusNoContent 56 | }) 57 | } 58 | -------------------------------------------------------------------------------- /_examples/advanced/router_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "net/http/httptest" 7 | "os" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | "github.com/swaggest/assertjson" 13 | ) 14 | 15 | func TestNewRouter(t *testing.T) { 16 | r := NewRouter() 17 | 18 | req, err := http.NewRequest(http.MethodGet, "/docs/openapi.json", nil) 19 | require.NoError(t, err) 20 | 21 | rw := httptest.NewRecorder() 22 | 23 | r.ServeHTTP(rw, req) 24 | assert.Equal(t, http.StatusOK, rw.Code) 25 | 26 | actualSchema, err := assertjson.MarshalIndentCompact(json.RawMessage(rw.Body.Bytes()), "", " ", 120) 27 | require.NoError(t, err) 28 | 29 | expectedSchema, err := os.ReadFile("_testdata/openapi.json") 30 | require.NoError(t, err) 31 | 32 | if !assertjson.Equal(t, expectedSchema, rw.Body.Bytes(), string(actualSchema)) { 33 | require.NoError(t, os.WriteFile("_testdata/openapi_last_run.json", actualSchema, 0o600)) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /_examples/advanced/validation.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/swaggest/usecase" 7 | ) 8 | 9 | func validation() usecase.Interactor { 10 | type inputPort struct { 11 | Header int `header:"X-Input" minimum:"10" description:"Request minimum: 10, response maximum: 20."` 12 | Query bool `query:"q" description:"This parameter will bypass explicit validation as it does not have constraints."` 13 | Data struct { 14 | Value string `json:"value" minLength:"3" description:"Request minLength: 3, response maxLength: 7"` 15 | } `json:"data" required:"true"` 16 | } 17 | 18 | type outputPort struct { 19 | Header int `header:"X-Output" json:"-" maximum:"20"` 20 | AnotherHeader bool `header:"X-Query" json:"-" description:"This header bypasses validation as it does not have constraints."` 21 | Data struct { 22 | Value string `json:"value" maxLength:"7"` 23 | } `json:"data" required:"true"` 24 | } 25 | 26 | u := usecase.NewIOI(new(inputPort), new(outputPort), func(ctx context.Context, input, output interface{}) (err error) { 27 | in := input.(*inputPort) 28 | out := output.(*outputPort) 29 | 30 | out.Header = in.Header 31 | out.AnotherHeader = in.Query 32 | out.Data.Value = in.Data.Value 33 | 34 | return nil 35 | }) 36 | 37 | u.SetTitle("Validation") 38 | u.SetDescription("Input/Output with validation.") 39 | 40 | return u 41 | } 42 | -------------------------------------------------------------------------------- /_examples/basic/README.md: -------------------------------------------------------------------------------- 1 | # Basic Example 2 | 3 | ```bash 4 | cd /tmp;go mod init foo;go get -u github.com/swaggest/rest/_examples/basic;go run github.com/swaggest/rest/_examples/basic;rm go.mod;rm go.sum 5 | ``` 6 | 7 | ``` 8 | 2020/12/02 12:27:18 http://localhost:8011/docs 9 | ``` 10 | 11 | ![Documentation Page](./screen.png) -------------------------------------------------------------------------------- /_examples/basic/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log" 8 | "net/http" 9 | "time" 10 | 11 | "github.com/swaggest/openapi-go/openapi3" 12 | "github.com/swaggest/rest/response/gzip" 13 | "github.com/swaggest/rest/web" 14 | swgui "github.com/swaggest/swgui/v5emb" 15 | "github.com/swaggest/usecase" 16 | "github.com/swaggest/usecase/status" 17 | ) 18 | 19 | func main() { 20 | s := web.NewService(openapi3.NewReflector()) 21 | 22 | // Init API documentation schema. 23 | s.OpenAPISchema().SetTitle("Basic Example") 24 | s.OpenAPISchema().SetDescription("This app showcases a trivial REST API.") 25 | s.OpenAPISchema().SetVersion("v1.2.3") 26 | 27 | // Setup middlewares. 28 | s.Wrap( 29 | gzip.Middleware, // Response compression with support for direct gzip pass through. 30 | ) 31 | 32 | // Declare input port type. 33 | type helloInput struct { 34 | Locale string `query:"locale" default:"en-US" pattern:"^[a-z]{2}-[A-Z]{2}$" enum:"ru-RU,en-US"` 35 | Name string `path:"name" minLength:"3"` // Field tags define parameter location and JSON schema constraints. 36 | } 37 | 38 | // Declare output port type. 39 | type helloOutput struct { 40 | Now time.Time `header:"X-Now" json:"-"` 41 | Message string `json:"message"` 42 | } 43 | 44 | messages := map[string]string{ 45 | "en-US": "Hello, %s!", 46 | "ru-RU": "Привет, %s!", 47 | } 48 | 49 | // Create use case interactor with references to input/output types and interaction function. 50 | u := usecase.NewIOI(new(helloInput), new(helloOutput), func(ctx context.Context, input, output interface{}) error { 51 | var ( 52 | in = input.(*helloInput) 53 | out = output.(*helloOutput) 54 | ) 55 | 56 | msg, available := messages[in.Locale] 57 | if !available { 58 | return status.Wrap(errors.New("unknown locale"), status.InvalidArgument) 59 | } 60 | 61 | out.Message = fmt.Sprintf(msg, in.Name) 62 | out.Now = time.Now() 63 | 64 | return nil 65 | }) 66 | 67 | // Describe use case interactor. 68 | u.SetTitle("Greeter") 69 | u.SetDescription("Greeter greets you.") 70 | 71 | u.SetExpectedErrors(status.InvalidArgument) 72 | 73 | // Add use case handler to router. 74 | s.Get("/hello/{name}", u) 75 | 76 | // Swagger UI endpoint at /docs. 77 | s.Docs("/docs", swgui.New) 78 | 79 | // Start server. 80 | log.Println("http://localhost:8011/docs") 81 | if err := http.ListenAndServe("localhost:8011", s); err != nil { 82 | log.Fatal(err) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /_examples/basic/screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swaggest/rest/a049dab5470b976f48f0b7afc85dcf108a44943c/_examples/basic/screen.png -------------------------------------------------------------------------------- /_examples/generic/main.go: -------------------------------------------------------------------------------- 1 | //go:build go1.18 2 | // +build go1.18 3 | 4 | package main 5 | 6 | import ( 7 | "context" 8 | "errors" 9 | "fmt" 10 | "log" 11 | "net/http" 12 | "time" 13 | 14 | "github.com/swaggest/openapi-go/openapi31" 15 | "github.com/swaggest/rest/response/gzip" 16 | "github.com/swaggest/rest/web" 17 | swgui "github.com/swaggest/swgui/v5emb" 18 | "github.com/swaggest/usecase" 19 | "github.com/swaggest/usecase/status" 20 | ) 21 | 22 | func main() { 23 | s := web.NewService(openapi31.NewReflector()) 24 | 25 | // Init API documentation schema. 26 | s.OpenAPISchema().SetTitle("Basic Example") 27 | s.OpenAPISchema().SetDescription("This app showcases a trivial REST API.") 28 | s.OpenAPISchema().SetVersion("v1.2.3") 29 | 30 | // Setup middlewares. 31 | s.Wrap( 32 | gzip.Middleware, // Response compression with support for direct gzip pass through. 33 | ) 34 | 35 | // Declare input port type. 36 | type helloInput struct { 37 | Locale string `query:"locale" default:"en-US" pattern:"^[a-z]{2}-[A-Z]{2}$" enum:"ru-RU,en-US"` 38 | Name string `path:"name" minLength:"3"` // Field tags define parameter location and JSON schema constraints. 39 | 40 | // Field tags of unnamed fields are applied to parent schema. 41 | // they are optional and can be used to disallow unknown parameters. 42 | // For non-body params, name tag must be provided explicitly. 43 | // E.g. here no unknown `query` and `cookie` parameters allowed, 44 | // unknown `header` params are ok. 45 | _ struct{} `query:"_" cookie:"_" additionalProperties:"false"` 46 | } 47 | 48 | // Declare output port type. 49 | type helloOutput struct { 50 | Now time.Time `header:"X-Now" json:"-"` 51 | Message string `json:"message"` 52 | } 53 | 54 | messages := map[string]string{ 55 | "en-US": "Hello, %s!", 56 | "ru-RU": "Привет, %s!", 57 | } 58 | 59 | // Create use case interactor with references to input/output types and interaction function. 60 | u := usecase.NewInteractor(func(ctx context.Context, input helloInput, output *helloOutput) error { 61 | msg, available := messages[input.Locale] 62 | if !available { 63 | return status.Wrap(errors.New("unknown locale"), status.InvalidArgument) 64 | } 65 | 66 | output.Message = fmt.Sprintf(msg, input.Name) 67 | output.Now = time.Now() 68 | 69 | return nil 70 | }) 71 | 72 | // Describe use case interactor. 73 | u.SetTitle("Greeter") 74 | u.SetDescription("Greeter greets you.") 75 | 76 | u.SetExpectedErrors(status.InvalidArgument) 77 | 78 | // Add use case handler to router. 79 | s.Get("/hello/{name}", u) 80 | 81 | // Swagger UI endpoint at /docs. 82 | s.Docs("/docs", swgui.New) 83 | 84 | // Start server. 85 | log.Println("http://localhost:8011/docs") 86 | if err := http.ListenAndServe("localhost:8011", s); err != nil { 87 | log.Fatal(err) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /_examples/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/swaggest/rest/_examples 2 | 3 | go 1.23.1 4 | 5 | replace github.com/swaggest/rest => ../ 6 | 7 | require ( 8 | github.com/bool64/ctxd v1.2.1 9 | github.com/bool64/dev v0.2.39 10 | github.com/bool64/httpmock v0.1.15 11 | github.com/bool64/httptestbench v0.1.4 12 | github.com/gin-gonic/gin v1.10.0 13 | github.com/go-chi/chi/v5 v5.2.1 14 | github.com/go-chi/jwtauth/v5 v5.3.1 15 | github.com/google/uuid v1.6.0 16 | github.com/kelseyhightower/envconfig v1.4.0 17 | github.com/rs/cors v1.11.1 18 | github.com/stretchr/testify v1.9.0 19 | github.com/swaggest/assertjson v1.9.0 20 | github.com/swaggest/jsonschema-go v0.3.73 21 | github.com/swaggest/openapi-go v0.2.57 22 | github.com/swaggest/rest v0.0.0-00010101000000-000000000000 23 | github.com/swaggest/swgui v1.8.2 24 | github.com/swaggest/usecase v1.3.1 25 | github.com/valyala/fasthttp v1.56.0 26 | ) 27 | 28 | require ( 29 | github.com/andybalholm/brotli v1.1.0 // indirect 30 | github.com/bool64/shared v0.1.5 // indirect 31 | github.com/bytedance/sonic v1.12.3 // indirect 32 | github.com/bytedance/sonic/loader v0.2.0 // indirect 33 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 34 | github.com/cloudwego/base64x v0.1.4 // indirect 35 | github.com/cloudwego/iasm v0.2.0 // indirect 36 | github.com/davecgh/go-spew v1.1.1 // indirect 37 | github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect 38 | github.com/fsnotify/fsnotify v1.5.4 // indirect 39 | github.com/gabriel-vasile/mimetype v1.4.5 // indirect 40 | github.com/gin-contrib/sse v0.1.0 // indirect 41 | github.com/go-playground/locales v0.14.1 // indirect 42 | github.com/go-playground/universal-translator v0.18.1 // indirect 43 | github.com/go-playground/validator/v10 v10.22.1 // indirect 44 | github.com/goccy/go-json v0.10.3 // indirect 45 | github.com/google/go-cmp v0.6.0 // indirect 46 | github.com/iancoleman/orderedmap v0.3.0 // indirect 47 | github.com/json-iterator/go v1.1.12 // indirect 48 | github.com/klauspost/compress v1.17.10 // indirect 49 | github.com/klauspost/cpuid/v2 v2.2.8 // indirect 50 | github.com/leodido/go-urn v1.4.0 // indirect 51 | github.com/lestrrat-go/blackmagic v1.0.2 // indirect 52 | github.com/lestrrat-go/httpcc v1.0.1 // indirect 53 | github.com/lestrrat-go/httprc v1.0.6 // indirect 54 | github.com/lestrrat-go/iter v1.0.2 // indirect 55 | github.com/lestrrat-go/jwx/v2 v2.1.1 // indirect 56 | github.com/lestrrat-go/option v1.0.1 // indirect 57 | github.com/mattn/go-isatty v0.0.20 // indirect 58 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 59 | github.com/modern-go/reflect2 v1.0.2 // indirect 60 | github.com/pelletier/go-toml/v2 v2.2.3 // indirect 61 | github.com/pmezard/go-difflib v1.0.0 // indirect 62 | github.com/santhosh-tekuri/jsonschema/v3 v3.1.0 // indirect 63 | github.com/segmentio/asm v1.2.0 // indirect 64 | github.com/sergi/go-diff v1.3.1 // indirect 65 | github.com/swaggest/form/v5 v5.1.1 // indirect 66 | github.com/swaggest/refl v1.3.1 // indirect 67 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 68 | github.com/ugorji/go/codec v1.2.12 // indirect 69 | github.com/valyala/bytebufferpool v1.0.0 // indirect 70 | github.com/vearutop/dynhist-go v1.1.0 // indirect 71 | github.com/vearutop/statigz v1.4.0 // indirect 72 | github.com/yosuke-furukawa/json5 v0.1.2-0.20201207051438-cf7bb3f354ff // indirect 73 | github.com/yudai/gojsondiff v1.0.0 // indirect 74 | github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect 75 | golang.org/x/arch v0.11.0 // indirect 76 | golang.org/x/crypto v0.28.0 // indirect 77 | golang.org/x/net v0.30.0 // indirect 78 | golang.org/x/sys v0.26.0 // indirect 79 | golang.org/x/text v0.19.0 // indirect 80 | google.golang.org/protobuf v1.34.2 // indirect 81 | gopkg.in/yaml.v2 v2.4.0 // indirect 82 | gopkg.in/yaml.v3 v3.0.1 // indirect 83 | ) 84 | -------------------------------------------------------------------------------- /_examples/jwtauth/main.go: -------------------------------------------------------------------------------- 1 | // Originally from https://github.com/go-chi/jwtauth/blob/v5.1.1/_example/main.go. 2 | // 3 | // jwtauth example 4 | // 5 | // Sample output: 6 | // 7 | // [peter@pak ~]$ curl -v http://localhost:3333/ 8 | // * Trying ::1... 9 | // * Connected to localhost (::1) port 3333 (#0) 10 | // > GET / HTTP/1.1 11 | // > Host: localhost:3333 12 | // > User-Agent: curl/7.49.1 13 | // > Accept: */* 14 | // > 15 | // < HTTP/1.1 200 OK 16 | // < Date: Tue, 13 Sep 2016 15:53:17 GMT 17 | // < Content-Length: 17 18 | // < Content-Type: text/plain; charset=utf-8 19 | // < 20 | // * Connection #0 to host localhost left intact 21 | // welcome anonymous% 22 | // 23 | // 24 | // [peter@pak ~]$ curl -v http://localhost:3333/admin 25 | // * Trying ::1... 26 | // * Connected to localhost (::1) port 3333 (#0) 27 | // > GET /admin HTTP/1.1 28 | // > Host: localhost:3333 29 | // > User-Agent: curl/7.49.1 30 | // > Accept: */* 31 | // > 32 | // < HTTP/1.1 401 Unauthorized 33 | // < Content-Type: text/plain; charset=utf-8 34 | // < X-Content-Type-Options: nosniff 35 | // < Date: Tue, 13 Sep 2016 15:53:19 GMT 36 | // < Content-Length: 13 37 | // < 38 | // Unauthorized 39 | // * Connection #0 to host localhost left intact 40 | // 41 | // 42 | // [peter@pak ~]$ curl -H"Authorization: BEARER eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxMjN9.PZLMJBT9OIVG2qgp9hQr685oVYFgRgWpcSPmNcw6y7M" -v http://localhost:3333/admin 43 | // * Trying ::1... 44 | // * Connected to localhost (::1) port 3333 (#0) 45 | // > GET /admin HTTP/1.1 46 | // > Host: localhost:3333 47 | // > User-Agent: curl/7.49.1 48 | // > Accept: */* 49 | // > Authorization: BEARER eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxMjN9.PZLMJBT9OIVG2qgp9hQr685oVYFgRgWpcSPmNcw6y7M 50 | // > 51 | // < HTTP/1.1 200 OK 52 | // < Date: Tue, 13 Sep 2016 15:54:26 GMT 53 | // < Content-Length: 22 54 | // < Content-Type: text/plain; charset=utf-8 55 | // < 56 | // * Connection #0 to host localhost left intact 57 | // protected area. hi 123% 58 | // 59 | 60 | package main 61 | 62 | import ( 63 | "context" 64 | "fmt" 65 | "net/http" 66 | 67 | "github.com/go-chi/chi/v5" 68 | jwtauth "github.com/go-chi/jwtauth/v5" 69 | "github.com/swaggest/openapi-go/openapi31" 70 | "github.com/swaggest/rest/nethttp" 71 | "github.com/swaggest/rest/web" 72 | "github.com/swaggest/usecase" 73 | ) 74 | 75 | var tokenAuth *jwtauth.JWTAuth 76 | 77 | func init() { 78 | tokenAuth = jwtauth.New("HS256", []byte("secret"), nil) 79 | 80 | // For debugging/example purposes, we generate and print 81 | // a sample jwt token with claims `user_id:123` here: 82 | _, tokenString, _ := tokenAuth.Encode(map[string]interface{}{"user_id": 123}) 83 | fmt.Printf("DEBUG: a sample jwt is %s\n\n", tokenString) 84 | } 85 | 86 | func main() { 87 | addr := "localhost:3333" 88 | fmt.Printf("Starting server on http://%v\n", addr) 89 | http.ListenAndServe(addr, router()) 90 | } 91 | 92 | func Get() usecase.Interactor { 93 | u := usecase.NewInteractor( 94 | func(ctx context.Context, _ struct{}, output *map[string]interface{}) error { 95 | jwt, claims, err := jwtauth.FromContext(ctx) 96 | fmt.Println(err) 97 | fmt.Println(jwt) 98 | fmt.Println(claims) 99 | 100 | *output = claims 101 | 102 | return nil 103 | }) 104 | return u 105 | } 106 | 107 | func router() http.Handler { 108 | s := web.NewService(openapi31.NewReflector()) 109 | 110 | s.Route("/admin", func(r chi.Router) { 111 | r.Group(func(r chi.Router) { 112 | r.Use( 113 | jwtauth.Verifier(tokenAuth), 114 | jwtauth.Authenticator(tokenAuth), 115 | ) 116 | 117 | r.Method(http.MethodGet, "/", nethttp.NewHandler(Get())) 118 | }) 119 | }) 120 | 121 | // Public routes 122 | s.Group(func(r chi.Router) { 123 | r.Get("/", func(w http.ResponseWriter, r *http.Request) { 124 | w.Write([]byte("welcome anonymous")) 125 | }) 126 | }) 127 | 128 | return s 129 | } 130 | -------------------------------------------------------------------------------- /_examples/mount/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | 9 | "github.com/go-chi/chi/v5/middleware" 10 | "github.com/swaggest/openapi-go" 11 | "github.com/swaggest/openapi-go/openapi3" 12 | "github.com/swaggest/rest/nethttp" 13 | "github.com/swaggest/rest/web" 14 | swgui "github.com/swaggest/swgui/v5emb" 15 | "github.com/swaggest/usecase" 16 | ) 17 | 18 | func mul() usecase.Interactor { 19 | return usecase.NewInteractor(func(ctx context.Context, input []int, output *int) error { 20 | *output = 1 21 | 22 | for _, v := range input { 23 | *output *= v 24 | } 25 | 26 | return nil 27 | }) 28 | } 29 | 30 | func sum() usecase.Interactor { 31 | return usecase.NewInteractor(func(ctx context.Context, input []int, output *int) error { 32 | for _, v := range input { 33 | *output += v 34 | } 35 | 36 | return nil 37 | }) 38 | } 39 | 40 | func service() *web.Service { 41 | s := web.NewService(openapi3.NewReflector()) 42 | s.OpenAPISchema().SetTitle("Security and Mount Example") 43 | 44 | apiV1 := web.NewService(openapi3.NewReflector()) 45 | apiV2 := web.NewService(openapi3.NewReflector()) 46 | 47 | apiV1.Wrap( 48 | middleware.BasicAuth("Admin Access", map[string]string{"admin": "admin"}), 49 | nethttp.HTTPBasicSecurityMiddleware(s.OpenAPICollector, "Admin", "Admin access"), 50 | nethttp.OpenAPIAnnotationsMiddleware(s.OpenAPICollector, func(oc openapi.OperationContext) error { 51 | oc.SetTags(append(oc.Tags(), "V1")...) 52 | return nil 53 | }), 54 | ) 55 | apiV1.Post("/sum", sum()) 56 | apiV1.Post("/mul", mul()) 57 | 58 | apiV2.Wrap( 59 | // No auth for V2. 60 | 61 | nethttp.OpenAPIAnnotationsMiddleware(s.OpenAPICollector, func(oc openapi.OperationContext) error { 62 | oc.SetTags(append(oc.Tags(), "V2")...) 63 | return nil 64 | }), 65 | ) 66 | apiV2.Post("/summarization", sum()) 67 | apiV2.Post("/multiplication", mul()) 68 | 69 | s.Mount("/api/v1", apiV1) 70 | s.Mount("/api/v2", apiV2) 71 | s.Docs("/api/docs", swgui.New) 72 | 73 | // Blanket handler, for example to serve static content. 74 | s.Mount("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 75 | _, _ = w.Write([]byte("blanket handler got a request: " + r.URL.String())) 76 | })) 77 | 78 | return s 79 | } 80 | 81 | func main() { 82 | fmt.Println("Swagger UI at http://localhost:8010/api/docs.") 83 | if err := http.ListenAndServe("localhost:8010", service()); err != nil { 84 | log.Fatal(err) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /_examples/mount/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | "github.com/swaggest/assertjson" 13 | ) 14 | 15 | func Test_service(t *testing.T) { 16 | s := service() 17 | 18 | rw := httptest.NewRecorder() 19 | r, err := http.NewRequest(http.MethodGet, "/api/docs/openapi.json", nil) 20 | require.NoError(t, err) 21 | 22 | s.ServeHTTP(rw, r) 23 | assertjson.EqMarshal(t, `{ 24 | "openapi":"3.0.3","info":{"title":"Security and Mount Example","version":""}, 25 | "paths":{ 26 | "/api/v1/mul":{ 27 | "post":{ 28 | "tags":["V1"],"summary":"Mul","operationId":"_examples/mount.mul", 29 | "requestBody":{ 30 | "content":{ 31 | "application/json":{"schema":{"type":"array","items":{"type":"integer"}}} 32 | } 33 | }, 34 | "responses":{ 35 | "200":{ 36 | "description":"OK", 37 | "content":{"application/json":{"schema":{"type":"integer"}}} 38 | }, 39 | "401":{ 40 | "description":"Unauthorized", 41 | "content":{ 42 | "application/json":{"schema":{"$ref":"#/components/schemas/RestErrResponse"}} 43 | } 44 | } 45 | }, 46 | "security":[{"Admin":[]}] 47 | } 48 | }, 49 | "/api/v1/sum":{ 50 | "post":{ 51 | "tags":["V1"],"summary":"Sum","operationId":"_examples/mount.sum", 52 | "requestBody":{ 53 | "content":{ 54 | "application/json":{"schema":{"type":"array","items":{"type":"integer"}}} 55 | } 56 | }, 57 | "responses":{ 58 | "200":{ 59 | "description":"OK", 60 | "content":{"application/json":{"schema":{"type":"integer"}}} 61 | }, 62 | "401":{ 63 | "description":"Unauthorized", 64 | "content":{ 65 | "application/json":{"schema":{"$ref":"#/components/schemas/RestErrResponse"}} 66 | } 67 | } 68 | }, 69 | "security":[{"Admin":[]}] 70 | } 71 | }, 72 | "/api/v2/multiplication":{ 73 | "post":{ 74 | "tags":["V2"],"summary":"Mul","operationId":"_examples/mount.mul2", 75 | "requestBody":{ 76 | "content":{ 77 | "application/json":{"schema":{"type":"array","items":{"type":"integer"}}} 78 | } 79 | }, 80 | "responses":{ 81 | "200":{ 82 | "description":"OK", 83 | "content":{"application/json":{"schema":{"type":"integer"}}} 84 | } 85 | } 86 | } 87 | }, 88 | "/api/v2/summarization":{ 89 | "post":{ 90 | "tags":["V2"],"summary":"Sum","operationId":"_examples/mount.sum2", 91 | "requestBody":{ 92 | "content":{ 93 | "application/json":{"schema":{"type":"array","items":{"type":"integer"}}} 94 | } 95 | }, 96 | "responses":{ 97 | "200":{ 98 | "description":"OK", 99 | "content":{"application/json":{"schema":{"type":"integer"}}} 100 | } 101 | } 102 | } 103 | } 104 | }, 105 | "components":{ 106 | "schemas":{ 107 | "RestErrResponse":{ 108 | "type":"object", 109 | "properties":{ 110 | "code":{"type":"integer","description":"Application-specific error code."}, 111 | "context":{ 112 | "type":"object","additionalProperties":{}, 113 | "description":"Application context." 114 | }, 115 | "error":{"type":"string","description":"Error message."}, 116 | "status":{"type":"string","description":"Status text."} 117 | } 118 | } 119 | }, 120 | "securitySchemes":{"Admin":{"type":"http","scheme":"basic","description":"Admin access"}} 121 | } 122 | }`, json.RawMessage(rw.Body.Bytes())) 123 | 124 | rw = httptest.NewRecorder() 125 | r, err = http.NewRequest(http.MethodPost, "/api/v2/multiplication", bytes.NewReader([]byte(`[1,2,3]`))) 126 | 127 | s.ServeHTTP(rw, r) 128 | assert.Equal(t, "6\n", rw.Body.String()) 129 | } 130 | -------------------------------------------------------------------------------- /_examples/task-api/.golangci.yml: -------------------------------------------------------------------------------- 1 | # See https://github.com/golangci/golangci-lint/blob/master/.golangci.example.yml 2 | run: 3 | tests: true 4 | deadline: 5m 5 | 6 | linters-settings: 7 | errcheck: 8 | check-type-assertions: true 9 | check-blank: true 10 | gocyclo: 11 | min-complexity: 20 12 | dupl: 13 | threshold: 100 14 | misspell: 15 | locale: US 16 | unused: 17 | check-exported: false 18 | unparam: 19 | check-exported: true 20 | 21 | linters: 22 | enable-all: true 23 | disable: 24 | - gochecknoglobals 25 | - testpackage 26 | - goerr113 27 | 28 | issues: 29 | exclude-use-default: false 30 | exclude-rules: 31 | - linters: 32 | - gomnd 33 | - goconst 34 | - goerr113 35 | - noctx 36 | - funlen 37 | path: "_test.go" 38 | -------------------------------------------------------------------------------- /_examples/task-api/Makefile: -------------------------------------------------------------------------------- 1 | GOLANGCI_LINT_VERSION := "v1.31.0" 2 | 3 | # The head of Makefile determines location of dev-go to include standard targets. 4 | GO ?= go 5 | export GO111MODULE = on 6 | 7 | ifneq "$(GOFLAGS)" "" 8 | $(info GOFLAGS: ${GOFLAGS}) 9 | endif 10 | 11 | ifneq "$(wildcard ./vendor )" "" 12 | $(info >> using vendor) 13 | modVendor = -mod=vendor 14 | ifeq (,$(findstring -mod,$(GOFLAGS))) 15 | export GOFLAGS := ${GOFLAGS} ${modVendor} 16 | endif 17 | ifneq "$(wildcard ./vendor/github.com/bool64/dev)" "" 18 | DEVGO_PATH := ./vendor/github.com/bool64/dev 19 | endif 20 | endif 21 | 22 | ifeq ($(DEVGO_PATH),) 23 | DEVGO_PATH := $(shell GO111MODULE=on $(GO) list ${modVendor} -f '{{.Dir}}' -m github.com/bool64/dev) 24 | ifeq ($(DEVGO_PATH),) 25 | $(info Module github.com/bool64/dev not found, downloading.) 26 | DEVGO_PATH := $(shell export GO111MODULE=on && $(GO) mod tidy && $(GO) list -f '{{.Dir}}' -m github.com/bool64/dev) 27 | endif 28 | endif 29 | 30 | -include $(DEVGO_PATH)/makefiles/main.mk 31 | -include $(DEVGO_PATH)/makefiles/test-unit.mk 32 | -include $(DEVGO_PATH)/makefiles/lint.mk 33 | 34 | ## Run tests 35 | test: test-unit 36 | -------------------------------------------------------------------------------- /_examples/task-api/dev_test.go: -------------------------------------------------------------------------------- 1 | package main_test 2 | 3 | import _ "github.com/bool64/dev" 4 | -------------------------------------------------------------------------------- /_examples/task-api/internal/domain/doc.go: -------------------------------------------------------------------------------- 1 | // Package domain defines application domain. 2 | package domain 3 | -------------------------------------------------------------------------------- /_examples/task-api/internal/domain/task/entity.go: -------------------------------------------------------------------------------- 1 | package task 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/swaggest/jsonschema-go" 7 | ) 8 | 9 | // Status describes task state. 10 | type Status string 11 | 12 | // Available task statuses. 13 | const ( 14 | Active = Status("") 15 | Canceled = Status("canceled") 16 | Done = Status("done") 17 | Expired = Status("expired") 18 | ) 19 | 20 | var _ jsonschema.Exposer = Status("") 21 | 22 | // JSONSchema exposes Status JSON schema, implements jsonschema.Exposer. 23 | func (Status) JSONSchema() (jsonschema.Schema, error) { 24 | s := jsonschema.Schema{} 25 | s. 26 | WithType(jsonschema.String.Type()). 27 | WithTitle("Goal Status"). 28 | WithDescription("Non-empty task status indicates result."). 29 | WithEnum(Active, Canceled, Done, Expired) 30 | 31 | return s, nil 32 | } 33 | 34 | // Identity identifies task. 35 | type Identity struct { 36 | ID int `json:"id"` 37 | } 38 | 39 | // Value is a task value. 40 | type Value struct { 41 | Goal string `json:"goal" minLength:"1" required:"true"` 42 | Deadline *time.Time `json:"deadline,omitempty"` 43 | } 44 | 45 | // Entity is an identified task entity. 46 | type Entity struct { 47 | Identity 48 | Value 49 | CreatedAt time.Time `json:"createdAt"` 50 | Status Status `json:"status,omitempty"` 51 | ClosedAt *time.Time `json:"closedAt,omitempty"` 52 | } 53 | -------------------------------------------------------------------------------- /_examples/task-api/internal/domain/task/service.go: -------------------------------------------------------------------------------- 1 | // Package task describes task domain. 2 | package task 3 | 4 | import "context" 5 | 6 | // Creator creates tasks. 7 | type Creator interface { 8 | Create(context.Context, Value) (Entity, error) 9 | } 10 | 11 | // Updater updates tasks. 12 | type Updater interface { 13 | Update(context.Context, Identity, Value) error 14 | } 15 | 16 | // Finisher closes tasks. 17 | type Finisher interface { 18 | Cancel(context.Context, Identity) error 19 | Finish(context.Context, Identity) error 20 | } 21 | 22 | // Finder finds tasks. 23 | type Finder interface { 24 | Find(context.Context) []Entity 25 | FindByID(context.Context, Identity) (Entity, error) 26 | } 27 | -------------------------------------------------------------------------------- /_examples/task-api/internal/infra/doc.go: -------------------------------------------------------------------------------- 1 | // Package infra implements domain services and application infrastructure. 2 | package infra 3 | -------------------------------------------------------------------------------- /_examples/task-api/internal/infra/init.go: -------------------------------------------------------------------------------- 1 | package infra 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "time" 7 | 8 | "github.com/swaggest/rest/_examples/task-api/internal/infra/repository" 9 | "github.com/swaggest/rest/_examples/task-api/internal/infra/service" 10 | ) 11 | 12 | // NewServiceLocator initializes application resources. 13 | func NewServiceLocator(cfg service.Config) *service.Locator { 14 | l := service.Locator{} 15 | 16 | taskRepository := repository.Task{} 17 | 18 | l.TaskFinisherProvider = &taskRepository 19 | l.TaskFinderProvider = &taskRepository 20 | l.TaskUpdaterProvider = &taskRepository 21 | l.TaskCreatorProvider = &taskRepository 22 | 23 | go func() { 24 | if cfg.TaskCleanupInterval == 0 { 25 | return 26 | } 27 | 28 | shutdown, done := l.ShutdownSignal("finishExpiredTasks") 29 | 30 | for { 31 | select { 32 | case <-time.After(cfg.TaskCleanupInterval): 33 | err := taskRepository.FinishExpired(context.Background()) 34 | if err != nil { 35 | log.Printf("failed to finish expired task: %v", err) 36 | } 37 | case <-shutdown: 38 | close(done) 39 | 40 | return 41 | } 42 | } 43 | }() 44 | 45 | return &l 46 | } 47 | -------------------------------------------------------------------------------- /_examples/task-api/internal/infra/log/usecase.go: -------------------------------------------------------------------------------- 1 | // Package log provides logging helpers. 2 | package log 3 | 4 | import ( 5 | "context" 6 | "io/ioutil" 7 | "log" 8 | 9 | "github.com/swaggest/usecase" 10 | ) 11 | 12 | // UseCaseMiddleware creates logging use case middleware. 13 | func UseCaseMiddleware() usecase.Middleware { 14 | return usecase.MiddlewareFunc(func(next usecase.Interactor) usecase.Interactor { 15 | if log.Writer() == ioutil.Discard { 16 | return next 17 | } 18 | 19 | var ( 20 | hasName usecase.HasName 21 | name = "unknown" 22 | ) 23 | 24 | if usecase.As(next, &hasName) { 25 | name = hasName.Name() 26 | } 27 | 28 | return usecase.Interact(func(ctx context.Context, input, output interface{}) error { 29 | err := next.Interact(ctx, input, output) 30 | if err != nil { 31 | log.Printf("usecase %s request (%v) failed: %v", name, input, err) 32 | } 33 | 34 | return err 35 | }) 36 | }) 37 | } 38 | -------------------------------------------------------------------------------- /_examples/task-api/internal/infra/nethttp/doc.go: -------------------------------------------------------------------------------- 1 | // Package nethttp defines HTTP API using net/http. 2 | package nethttp 3 | -------------------------------------------------------------------------------- /_examples/task-api/internal/infra/nethttp/router.go: -------------------------------------------------------------------------------- 1 | package nethttp 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | 7 | "github.com/go-chi/chi/v5" 8 | "github.com/go-chi/chi/v5/middleware" 9 | "github.com/swaggest/openapi-go" 10 | "github.com/swaggest/openapi-go/openapi3" 11 | "github.com/swaggest/rest" 12 | "github.com/swaggest/rest/_examples/task-api/internal/infra/schema" 13 | "github.com/swaggest/rest/_examples/task-api/internal/infra/service" 14 | "github.com/swaggest/rest/_examples/task-api/internal/usecase" 15 | "github.com/swaggest/rest/nethttp" 16 | "github.com/swaggest/rest/web" 17 | swgui "github.com/swaggest/swgui/v5emb" 18 | ) 19 | 20 | // NewRouter creates HTTP router. 21 | func NewRouter(locator *service.Locator) http.Handler { 22 | s := web.NewService(openapi3.NewReflector()) 23 | 24 | schema.SetupOpenAPICollector(s.OpenAPICollector) 25 | 26 | adminAuth := middleware.BasicAuth("Admin Access", map[string]string{"admin": "admin"}) 27 | userAuth := middleware.BasicAuth("User Access", map[string]string{"user": "user"}) 28 | 29 | s.Wrap( 30 | middleware.NoCache, 31 | middleware.Timeout(time.Second), 32 | ) 33 | 34 | ff := func(h *nethttp.Handler) { 35 | h.ReqMapping = rest.RequestMapping{rest.ParamInPath: map[string]string{"ID": "id"}} 36 | } 37 | 38 | // Unrestricted access. 39 | s.Route("/dev", func(r chi.Router) { 40 | r.Use(nethttp.OpenAPIAnnotationsMiddleware(s.OpenAPICollector, func(oc openapi.OperationContext) error { 41 | oc.SetTags("Dev Mode") 42 | 43 | return nil 44 | })) 45 | r.Group(func(r chi.Router) { 46 | r.Method(http.MethodPost, "/tasks", nethttp.NewHandler(usecase.CreateTask(locator), 47 | nethttp.SuccessStatus(http.StatusCreated))) 48 | r.Method(http.MethodPut, "/tasks/{id}", nethttp.NewHandler(usecase.UpdateTask(locator), ff)) 49 | r.Method(http.MethodGet, "/tasks/{id}", nethttp.NewHandler(usecase.FindTask(locator), ff)) 50 | r.Method(http.MethodGet, "/tasks", nethttp.NewHandler(usecase.FindTasks(locator))) 51 | r.Method(http.MethodDelete, "/tasks/{id}", nethttp.NewHandler(usecase.FinishTask(locator), ff)) 52 | }) 53 | }) 54 | 55 | // Endpoints with admin access. 56 | s.Route("/admin", func(r chi.Router) { 57 | r.Group(func(r chi.Router) { 58 | r.Use(nethttp.OpenAPIAnnotationsMiddleware(s.OpenAPICollector, func(oc openapi.OperationContext) error { 59 | oc.SetTags("Admin Mode") 60 | 61 | return nil 62 | })) 63 | r.Use(adminAuth, nethttp.HTTPBasicSecurityMiddleware(s.OpenAPICollector, "Admin", "Admin access")) 64 | r.Method(http.MethodPut, "/tasks/{id}", nethttp.NewHandler(usecase.UpdateTask(locator), ff)) 65 | }) 66 | }) 67 | 68 | // Endpoints with user access. 69 | s.Route("/user", func(r chi.Router) { 70 | r.Group(func(r chi.Router) { 71 | r.Use(userAuth, nethttp.HTTPBasicSecurityMiddleware(s.OpenAPICollector, "User", "User access")) 72 | r.Method(http.MethodPost, "/tasks", nethttp.NewHandler(usecase.CreateTask(locator), 73 | nethttp.SuccessStatus(http.StatusCreated))) 74 | }) 75 | }) 76 | 77 | // Swagger UI endpoint at /docs. 78 | s.Docs("/docs", swgui.New) 79 | 80 | s.Mount("/debug", middleware.Profiler()) 81 | 82 | return s 83 | } 84 | -------------------------------------------------------------------------------- /_examples/task-api/internal/infra/schema/openapi.go: -------------------------------------------------------------------------------- 1 | // Package schema instruments OpenAPI schema. 2 | package schema 3 | 4 | import ( 5 | "github.com/swaggest/rest/openapi" 6 | ) 7 | 8 | // SetupOpenAPICollector sets up API documentation collector. 9 | func SetupOpenAPICollector(apiSchema *openapi.Collector) { 10 | apiSchema.SpecSchema().SetTitle("Tasks Service") 11 | apiSchema.SpecSchema().SetDescription("This example service manages tasks.") 12 | apiSchema.SpecSchema().SetVersion("1.2.3") 13 | } 14 | -------------------------------------------------------------------------------- /_examples/task-api/internal/infra/service/config.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import "time" 4 | 5 | // Config defines application settings. 6 | type Config struct { 7 | HTTPPort int `envconfig:"HTTP_PORT" default:"8010"` 8 | 9 | TaskCleanupInterval time.Duration `envconfig:"TASK_CLEANUP_INTERVAL" default:"30s"` 10 | } 11 | -------------------------------------------------------------------------------- /_examples/task-api/internal/infra/service/doc.go: -------------------------------------------------------------------------------- 1 | // Package service defines application services. 2 | package service 3 | -------------------------------------------------------------------------------- /_examples/task-api/internal/infra/service/locator.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import "github.com/swaggest/rest/_examples/task-api/pkg/graceful" 4 | 5 | // Locator defines application services. 6 | type Locator struct { 7 | graceful.Shutdown 8 | 9 | TaskCreatorProvider 10 | TaskUpdaterProvider 11 | TaskFinderProvider 12 | TaskFinisherProvider 13 | } 14 | -------------------------------------------------------------------------------- /_examples/task-api/internal/infra/service/provider.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import "github.com/swaggest/rest/_examples/task-api/internal/domain/task" 4 | 5 | // TaskCreatorProvider is a service locator provider. 6 | type TaskCreatorProvider interface { 7 | TaskCreator() task.Creator 8 | } 9 | 10 | // TaskUpdaterProvider is a service locator provider. 11 | type TaskUpdaterProvider interface { 12 | TaskUpdater() task.Updater 13 | } 14 | 15 | // TaskFinderProvider is a service locator provider. 16 | type TaskFinderProvider interface { 17 | TaskFinder() task.Finder 18 | } 19 | 20 | // TaskFinisherProvider is a service locator provider. 21 | type TaskFinisherProvider interface { 22 | TaskFinisher() task.Finisher 23 | } 24 | -------------------------------------------------------------------------------- /_examples/task-api/internal/usecase/create_task.go: -------------------------------------------------------------------------------- 1 | package usecase 2 | 3 | import ( 4 | "context" 5 | "log" 6 | 7 | "github.com/swaggest/rest/_examples/task-api/internal/domain/task" 8 | "github.com/swaggest/usecase" 9 | "github.com/swaggest/usecase/status" 10 | ) 11 | 12 | // CreateTask creates usecase interactor. 13 | func CreateTask( 14 | deps interface { 15 | TaskCreator() task.Creator 16 | }, 17 | ) usecase.IOInteractor { 18 | u := usecase.NewIOI(new(task.Value), new(task.Entity), func(ctx context.Context, input, output interface{}) error { 19 | var ( 20 | in = input.(*task.Value) 21 | out = output.(*task.Entity) 22 | err error 23 | ) 24 | 25 | log.Printf("creating task: %v\n", *in) 26 | *out, err = deps.TaskCreator().Create(ctx, *in) 27 | 28 | return err 29 | }) 30 | 31 | u.SetDescription("Create task to be done.") 32 | u.SetExpectedErrors( 33 | status.AlreadyExists, 34 | status.InvalidArgument, 35 | ) 36 | u.SetTags("Tasks") 37 | 38 | return u 39 | } 40 | -------------------------------------------------------------------------------- /_examples/task-api/internal/usecase/doc.go: -------------------------------------------------------------------------------- 1 | // Package usecase defines application use cases. 2 | package usecase 3 | -------------------------------------------------------------------------------- /_examples/task-api/internal/usecase/find_task.go: -------------------------------------------------------------------------------- 1 | package usecase 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/swaggest/rest/_examples/task-api/internal/domain/task" 7 | "github.com/swaggest/usecase" 8 | "github.com/swaggest/usecase/status" 9 | ) 10 | 11 | // FindTask creates usecase interactor. 12 | func FindTask( 13 | deps interface { 14 | TaskFinder() task.Finder 15 | }, 16 | ) usecase.IOInteractor { 17 | u := usecase.NewIOI(new(task.Identity), new(task.Entity), 18 | func(ctx context.Context, input, output interface{}) error { 19 | var ( 20 | in = input.(*task.Identity) 21 | out = output.(*task.Entity) 22 | err error 23 | ) 24 | 25 | *out, err = deps.TaskFinder().FindByID(ctx, *in) 26 | 27 | return err 28 | }) 29 | 30 | u.SetDescription("Find task by ID.") 31 | u.SetExpectedErrors( 32 | status.NotFound, 33 | status.InvalidArgument, 34 | ) 35 | u.SetTags("Tasks") 36 | 37 | return u 38 | } 39 | -------------------------------------------------------------------------------- /_examples/task-api/internal/usecase/find_tasks.go: -------------------------------------------------------------------------------- 1 | package usecase 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/swaggest/rest/_examples/task-api/internal/domain/task" 8 | "github.com/swaggest/usecase" 9 | "github.com/swaggest/usecase/status" 10 | ) 11 | 12 | // FindTasks creates usecase interactor. 13 | func FindTasks( 14 | deps interface { 15 | TaskFinder() task.Finder 16 | }, 17 | ) usecase.IOInteractor { 18 | u := usecase.NewIOI(nil, new([]task.Entity), func(ctx context.Context, input, output interface{}) error { 19 | out, ok := output.(*[]task.Entity) 20 | if !ok { 21 | return fmt.Errorf("%w: unexpected output type %T", status.Unimplemented, output) 22 | } 23 | 24 | *out = deps.TaskFinder().Find(ctx) 25 | 26 | return nil 27 | }) 28 | 29 | u.SetDescription("Find all tasks.") 30 | u.Output = new([]task.Entity) 31 | u.SetTags("Tasks") 32 | 33 | return u 34 | } 35 | -------------------------------------------------------------------------------- /_examples/task-api/internal/usecase/finish_task.go: -------------------------------------------------------------------------------- 1 | package usecase 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/swaggest/rest/_examples/task-api/internal/domain/task" 7 | "github.com/swaggest/usecase" 8 | "github.com/swaggest/usecase/status" 9 | ) 10 | 11 | type finishTaskDeps interface { 12 | TaskFinisher() task.Finisher 13 | } 14 | 15 | // FinishTask creates usecase interactor. 16 | func FinishTask(deps finishTaskDeps) usecase.IOInteractor { 17 | u := usecase.NewIOI(new(task.Identity), nil, func(ctx context.Context, input, _ interface{}) error { 18 | var ( 19 | in = input.(*task.Identity) 20 | err error 21 | ) 22 | 23 | err = deps.TaskFinisher().Finish(ctx, *in) 24 | 25 | return err 26 | }) 27 | 28 | u.SetDescription("Finish task by ID.") 29 | u.SetExpectedErrors( 30 | status.NotFound, 31 | status.InvalidArgument, 32 | ) 33 | u.SetTags("Tasks") 34 | 35 | return u 36 | } 37 | -------------------------------------------------------------------------------- /_examples/task-api/internal/usecase/update_task.go: -------------------------------------------------------------------------------- 1 | package usecase 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/swaggest/rest/_examples/task-api/internal/domain/task" 7 | "github.com/swaggest/usecase" 8 | "github.com/swaggest/usecase/status" 9 | ) 10 | 11 | type updateTask struct { 12 | task.Identity `json:"-"` 13 | task.Value 14 | } 15 | 16 | // UpdateTask creates usecase interactor. 17 | func UpdateTask( 18 | deps interface { 19 | TaskUpdater() task.Updater 20 | }, 21 | ) usecase.Interactor { 22 | u := usecase.NewIOI(new(updateTask), nil, func(ctx context.Context, input, _ interface{}) error { 23 | var ( 24 | in = input.(*updateTask) 25 | err error 26 | ) 27 | 28 | err = deps.TaskUpdater().Update(ctx, in.Identity, in.Value) 29 | 30 | return err 31 | }) 32 | 33 | u.SetDescription("Update existing task.") 34 | u.SetExpectedErrors( 35 | status.InvalidArgument, 36 | ) 37 | u.SetTags("Tasks") 38 | 39 | return u 40 | } 41 | -------------------------------------------------------------------------------- /_examples/task-api/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/kelseyhightower/envconfig" 10 | "github.com/swaggest/rest/_examples/task-api/internal/infra" 11 | "github.com/swaggest/rest/_examples/task-api/internal/infra/nethttp" 12 | "github.com/swaggest/rest/_examples/task-api/internal/infra/service" 13 | ) 14 | 15 | func main() { 16 | // Initialize config from ENV vars. 17 | cfg := service.Config{} 18 | 19 | if err := envconfig.Process("", &cfg); err != nil { 20 | log.Fatal(err) 21 | } 22 | 23 | // Initialize application resources. 24 | l := infra.NewServiceLocator(cfg) 25 | 26 | // Terminate service locator on CTRL+C (SIGTERM or SIGINT). 27 | l.EnableGracefulShutdown() 28 | 29 | // Initialize HTTP server. 30 | srv := http.Server{ 31 | Addr: fmt.Sprintf(":%d", cfg.HTTPPort), Handler: nethttp.NewRouter(l), 32 | ReadHeaderTimeout: time.Second, 33 | } 34 | 35 | // Start HTTP server. 36 | log.Printf("starting HTTP server at http://localhost:%d/docs\n", cfg.HTTPPort) 37 | 38 | go func() { 39 | err := srv.ListenAndServe() 40 | if err != nil { 41 | log.Fatal(err) 42 | } 43 | }() 44 | 45 | // Wait for termination signal and HTTP shutdown finished. 46 | err := l.WaitToShutdownHTTP(&srv, "http") 47 | if err != nil { 48 | log.Fatal(err) 49 | } 50 | 51 | // Wait for service locator termination finished. 52 | err = l.Wait() 53 | if err != nil { 54 | log.Fatal(err) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /_examples/task-api/pkg/graceful/doc.go: -------------------------------------------------------------------------------- 1 | // Package graceful provides an orchestrator for graceful shutdown. 2 | package graceful 3 | -------------------------------------------------------------------------------- /_examples/task-api/pkg/graceful/http.go: -------------------------------------------------------------------------------- 1 | package graceful 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | ) 7 | 8 | // WaitToShutdownHTTP synchronously waits for shutdown signal and shutdowns http server. 9 | func (s *Shutdown) WaitToShutdownHTTP(server *http.Server, subscriber string) error { 10 | shutdown, done := s.ShutdownSignal(subscriber) 11 | 12 | <-shutdown 13 | 14 | s.mu.Lock() 15 | timeout := s.Timeout 16 | s.mu.Unlock() 17 | 18 | if timeout == 0 { 19 | timeout = DefaultTimeout 20 | } 21 | 22 | ctx, cancel := context.WithTimeout(context.Background(), timeout) 23 | defer cancel() 24 | 25 | err := server.Shutdown(ctx) 26 | 27 | close(done) 28 | 29 | return err 30 | } 31 | -------------------------------------------------------------------------------- /_examples/task-api/pkg/graceful/shutdown.go: -------------------------------------------------------------------------------- 1 | package graceful 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/signal" 7 | "sync" 8 | "syscall" 9 | "time" 10 | ) 11 | 12 | // DefaultTimeout is a default Timeout to wait for graceful termination. 13 | const DefaultTimeout = 10 * time.Second 14 | 15 | // Shutdown manages graceful shutdown. 16 | type Shutdown struct { 17 | Timeout time.Duration 18 | 19 | mu sync.Mutex 20 | subscribers map[string]chan struct{} 21 | shutdownSignal chan struct{} 22 | } 23 | 24 | // Close invokes shutdown. 25 | func (s *Shutdown) Close() { 26 | s.mu.Lock() 27 | defer s.mu.Unlock() 28 | 29 | if s.shutdownSignal != nil { 30 | close(s.shutdownSignal) 31 | s.shutdownSignal = nil 32 | } 33 | } 34 | 35 | // Wait blocks until shutdown. 36 | func (s *Shutdown) Wait() error { 37 | if s.shutdownSignal != nil { 38 | <-s.shutdownSignal 39 | } 40 | 41 | return s.shutdown() 42 | } 43 | 44 | // EnableGracefulShutdown schedules service locator termination SIGTERM or SIGINT. 45 | func (s *Shutdown) EnableGracefulShutdown() { 46 | s.mu.Lock() 47 | defer s.mu.Unlock() 48 | 49 | if s.shutdownSignal == nil { 50 | shutdownSignal := make(chan struct{}) 51 | s.shutdownSignal = shutdownSignal 52 | 53 | exit := make(chan os.Signal, 1) 54 | signal.Notify(exit, syscall.SIGTERM, syscall.SIGINT) 55 | 56 | go func() { 57 | <-exit 58 | close(shutdownSignal) 59 | s.mu.Lock() 60 | defer s.mu.Unlock() 61 | 62 | if s.shutdownSignal != nil { 63 | s.shutdownSignal = nil 64 | } 65 | }() 66 | } 67 | } 68 | 69 | func (s *Shutdown) shutdown() error { 70 | s.mu.Lock() 71 | defer s.mu.Unlock() 72 | 73 | timeout := s.Timeout 74 | if timeout == 0 { 75 | timeout = DefaultTimeout 76 | } 77 | 78 | deadline := time.After(timeout) 79 | 80 | for subscriber, done := range s.subscribers { 81 | select { 82 | case <-done: 83 | continue 84 | case <-deadline: 85 | return fmt.Errorf("shutdown deadline exceeded while waiting for %s", subscriber) 86 | } 87 | } 88 | 89 | return nil 90 | } 91 | 92 | // ShutdownSignal returns a channel that is closed when service locator is closed or os shutdownSignal is received and 93 | // a confirmation channel that should be closed once subscriber has finished the shutdown. 94 | func (s *Shutdown) ShutdownSignal(subscriber string) (shutdown <-chan struct{}, done chan<- struct{}) { 95 | s.mu.Lock() 96 | defer s.mu.Unlock() 97 | 98 | if s.subscribers == nil { 99 | s.subscribers = make(map[string]chan struct{}) 100 | } 101 | 102 | if d, ok := s.subscribers[subscriber]; ok { 103 | return s.shutdownSignal, d 104 | } 105 | 106 | d := make(chan struct{}, 1) 107 | s.subscribers[subscriber] = d 108 | 109 | return s.shutdownSignal, d 110 | } 111 | -------------------------------------------------------------------------------- /_examples/tools_test.go: -------------------------------------------------------------------------------- 1 | package examples_test 2 | 3 | import _ "github.com/bool64/dev" 4 | -------------------------------------------------------------------------------- /chirouter/doc.go: -------------------------------------------------------------------------------- 1 | // Package chirouter provides instrumentation for chi.Router. 2 | package chirouter 3 | -------------------------------------------------------------------------------- /chirouter/example_test.go: -------------------------------------------------------------------------------- 1 | package chirouter_test 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "net/http" 7 | "net/http/httptest" 8 | 9 | "github.com/go-chi/chi/v5" 10 | "github.com/swaggest/rest" 11 | "github.com/swaggest/rest/chirouter" 12 | "github.com/swaggest/rest/request" 13 | ) 14 | 15 | func ExamplePathToURLValues() { 16 | // Instantiate decoder factory with gorillamux.PathToURLValues. 17 | // Single factory can be used to create multiple request decoders. 18 | decoderFactory := request.NewDecoderFactory() 19 | decoderFactory.ApplyDefaults = true 20 | decoderFactory.SetDecoderFunc(rest.ParamInPath, chirouter.PathToURLValues) 21 | 22 | // Define request structure for your HTTP handler. 23 | type myRequest struct { 24 | Query1 int `query:"query1"` 25 | Path1 string `path:"path1"` 26 | Path2 int `path:"path2"` 27 | Header1 float64 `header:"X-Header-1"` 28 | FormData1 bool `formData:"formData1"` 29 | FormData2 string `formData:"formData2"` 30 | } 31 | 32 | // Create decoder for that request structure. 33 | dec := decoderFactory.MakeDecoder(http.MethodPost, myRequest{}, nil) 34 | 35 | router := chi.NewRouter() 36 | 37 | // Now in router handler you can decode *http.Request into a Go structure. 38 | router.Handle("/foo/{path1}/bar/{path2}", http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { 39 | var in myRequest 40 | 41 | _ = dec.Decode(r, &in, nil) 42 | 43 | fmt.Printf("%+v\n", in) 44 | })) 45 | 46 | // Serving example URL. 47 | w := httptest.NewRecorder() 48 | 49 | req, _ := http.NewRequest(http.MethodPost, `/foo/a%2Fbc/bar/123?query1=321`, 50 | bytes.NewBufferString("formData1=true&formData2=def")) 51 | 52 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 53 | req.Header.Set("X-Header-1", "1.23") 54 | 55 | router.ServeHTTP(w, req) 56 | // Output: 57 | // {Query1:321 Path1:a/bc Path2:123 Header1:1.23 FormData1:true FormData2:def} 58 | } 59 | -------------------------------------------------------------------------------- /chirouter/path_decoder.go: -------------------------------------------------------------------------------- 1 | package chirouter 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/url" 7 | 8 | "github.com/go-chi/chi/v5" 9 | ) 10 | 11 | // PathToURLValues is a decoder function for parameters in path. 12 | func PathToURLValues(r *http.Request) (url.Values, error) { 13 | if routeCtx := chi.RouteContext(r.Context()); routeCtx != nil { 14 | params := make(url.Values, len(routeCtx.URLParams.Keys)) 15 | 16 | for i, key := range routeCtx.URLParams.Keys { 17 | value := routeCtx.URLParams.Values[i] 18 | 19 | value, err := url.PathUnescape(value) 20 | if err != nil { 21 | return nil, fmt.Errorf("unescaping path: %w", err) 22 | } 23 | 24 | params[key] = []string{value} 25 | } 26 | 27 | return params, nil 28 | } 29 | 30 | return nil, nil 31 | } 32 | -------------------------------------------------------------------------------- /dev_test.go: -------------------------------------------------------------------------------- 1 | package rest_test 2 | 3 | import _ "github.com/bool64/dev" // Include CI/Dev scripts to project. 4 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | // Package rest provides http handler for use case interactor to implement REST API. 2 | package rest 3 | -------------------------------------------------------------------------------- /error_test.go: -------------------------------------------------------------------------------- 1 | package rest_test 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "net/http" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/swaggest/rest" 11 | "github.com/swaggest/usecase" 12 | "github.com/swaggest/usecase/status" 13 | ) 14 | 15 | func TestHTTPStatusFromCanonicalCode(t *testing.T) { 16 | maxStatusCode := 17 17 | for i := 0; i <= maxStatusCode; i++ { 18 | s := status.Code(i) 19 | assert.NotEmpty(t, rest.HTTPStatusFromCanonicalCode(s)) 20 | } 21 | } 22 | 23 | type errWithHTTPStatus int 24 | 25 | func (e errWithHTTPStatus) Error() string { 26 | return "failed very much" 27 | } 28 | 29 | func (e errWithHTTPStatus) HTTPStatus() int { 30 | return int(e) 31 | } 32 | 33 | func TestErr(t *testing.T) { 34 | err := usecase.Error{ 35 | StatusCode: status.InvalidArgument, 36 | Value: errors.New("failed"), 37 | Context: map[string]interface{}{"hello": "world"}, 38 | } 39 | code, er := rest.Err(err) 40 | 41 | assert.Equal(t, http.StatusBadRequest, code) 42 | assert.Equal(t, map[string]interface{}{"hello": "world"}, er.Context) 43 | assert.Equal(t, "invalid argument: failed", er.Error()) 44 | assert.Equal(t, "INVALID_ARGUMENT", er.StatusText) 45 | assert.Equal(t, 0, er.AppCode) 46 | assert.Equal(t, err, er.Unwrap()) 47 | 48 | j, jErr := json.Marshal(er) 49 | assert.NoError(t, jErr) 50 | assert.Equal(t, 51 | `{"status":"INVALID_ARGUMENT","error":"invalid argument: failed","context":{"hello":"world"}}`, 52 | string(j), 53 | ) 54 | 55 | code, er = rest.Err(status.DataLoss) 56 | 57 | assert.Equal(t, http.StatusInternalServerError, code) 58 | assert.Nil(t, er.Context) 59 | assert.Equal(t, "data loss", er.Error()) 60 | assert.Equal(t, "DATA_LOSS", er.StatusText) 61 | assert.Equal(t, 0, er.AppCode) 62 | assert.Equal(t, status.DataLoss, er.Unwrap()) 63 | 64 | err = usecase.Error{ 65 | AppCode: 123, 66 | StatusCode: status.InvalidArgument, 67 | Value: errors.New("failed"), 68 | } 69 | code, er = rest.Err(err) 70 | 71 | assert.Nil(t, er.Context) 72 | assert.Equal(t, http.StatusBadRequest, code) 73 | assert.Equal(t, "invalid argument: failed", er.Error()) 74 | assert.Equal(t, "INVALID_ARGUMENT", er.StatusText) 75 | assert.Equal(t, 123, er.AppCode) 76 | assert.Equal(t, err, er.Unwrap()) 77 | 78 | code, er = rest.Err(errWithHTTPStatus(http.StatusTeapot)) 79 | 80 | assert.Nil(t, er.Context) 81 | assert.Equal(t, http.StatusTeapot, code) 82 | assert.Equal(t, "failed very much", er.Error()) 83 | assert.Equal(t, "", er.StatusText) 84 | assert.Equal(t, 0, er.AppCode) 85 | 86 | assert.Panics(t, func() { 87 | _, er := rest.Err(nil) 88 | assert.NoError(t, er) 89 | }) 90 | } 91 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/swaggest/rest 2 | 3 | go 1.17 4 | 5 | require ( 6 | github.com/bool64/dev v0.2.39 7 | github.com/bool64/httpmock v0.1.15 8 | github.com/bool64/shared v0.1.5 9 | github.com/cespare/xxhash/v2 v2.3.0 10 | github.com/go-chi/chi/v5 v5.2.1 11 | github.com/gorilla/mux v1.8.1 12 | github.com/santhosh-tekuri/jsonschema/v3 v3.1.0 13 | github.com/stretchr/testify v1.8.2 14 | github.com/swaggest/assertjson v1.9.0 15 | github.com/swaggest/form/v5 v5.1.1 16 | github.com/swaggest/jsonschema-go v0.3.73 17 | github.com/swaggest/openapi-go v0.2.57 18 | github.com/swaggest/refl v1.3.1 19 | github.com/swaggest/usecase v1.3.1 20 | ) 21 | 22 | require ( 23 | github.com/davecgh/go-spew v1.1.1 // indirect 24 | github.com/iancoleman/orderedmap v0.3.0 // indirect 25 | github.com/pmezard/go-difflib v1.0.0 // indirect 26 | github.com/sergi/go-diff v1.3.1 // indirect 27 | github.com/yosuke-furukawa/json5 v0.1.2-0.20201207051438-cf7bb3f354ff // indirect 28 | github.com/yudai/gojsondiff v1.0.0 // indirect 29 | github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect 30 | gopkg.in/yaml.v2 v2.4.0 // indirect 31 | gopkg.in/yaml.v3 v3.0.1 // indirect 32 | ) 33 | -------------------------------------------------------------------------------- /gorillamux/doc.go: -------------------------------------------------------------------------------- 1 | // Package gorillamux provides OpenAPI docs collector for gorilla/mux web services. 2 | package gorillamux 3 | -------------------------------------------------------------------------------- /gorillamux/example_test.go: -------------------------------------------------------------------------------- 1 | package gorillamux_test 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "net/http" 7 | "net/http/httptest" 8 | 9 | "github.com/gorilla/mux" 10 | "github.com/swaggest/rest" 11 | "github.com/swaggest/rest/gorillamux" 12 | "github.com/swaggest/rest/request" 13 | ) 14 | 15 | func ExamplePathToURLValues() { 16 | // Instantiate decoder factory with gorillamux.PathToURLValues. 17 | // Single factory can be used to create multiple request decoders. 18 | decoderFactory := request.NewDecoderFactory() 19 | decoderFactory.ApplyDefaults = true 20 | decoderFactory.SetDecoderFunc(rest.ParamInPath, gorillamux.PathToURLValues) 21 | 22 | // Define request structure for your HTTP handler. 23 | type myRequest struct { 24 | Query1 int `query:"query1"` 25 | Path1 string `path:"path1"` 26 | Path2 int `path:"path2"` 27 | Header1 float64 `header:"X-Header-1"` 28 | FormData1 bool `formData:"formData1"` 29 | FormData2 string `formData:"formData2"` 30 | } 31 | 32 | // Create decoder for that request structure. 33 | dec := decoderFactory.MakeDecoder(http.MethodPost, myRequest{}, nil) 34 | 35 | router := mux.NewRouter() 36 | 37 | // Now in router handler you can decode *http.Request into a Go structure. 38 | router.Handle("/foo/{path1}/bar/{path2}", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 39 | var in myRequest 40 | 41 | _ = dec.Decode(r, &in, nil) 42 | 43 | fmt.Printf("%+v\n", in) 44 | })) 45 | 46 | // Serving example URL. 47 | w := httptest.NewRecorder() 48 | req, _ := http.NewRequest(http.MethodPost, "/foo/abc/bar/123?query1=321", 49 | bytes.NewBufferString("formData1=true&formData2=def")) 50 | 51 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 52 | req.Header.Set("X-Header-1", "1.23") 53 | 54 | router.ServeHTTP(w, req) 55 | // Output: 56 | // {Query1:321 Path1:abc Path2:123 Header1:1.23 FormData1:true FormData2:def} 57 | } 58 | -------------------------------------------------------------------------------- /gorillamux/path_decoder.go: -------------------------------------------------------------------------------- 1 | package gorillamux 2 | 3 | import ( 4 | "net/http" 5 | "net/url" 6 | 7 | "github.com/gorilla/mux" 8 | ) 9 | 10 | // PathToURLValues is a decoder function for parameters in path. 11 | func PathToURLValues(r *http.Request) (url.Values, error) { //nolint:unparam // Matches request.DecoderFactory.SetDecoderFunc. 12 | muxVars := mux.Vars(r) 13 | res := make(url.Values, len(muxVars)) 14 | 15 | for k, v := range muxVars { 16 | res.Set(k, v) 17 | } 18 | 19 | return res, nil 20 | } 21 | -------------------------------------------------------------------------------- /gzip/container.go: -------------------------------------------------------------------------------- 1 | package gzip 2 | 3 | import ( 4 | "bytes" 5 | "compress/gzip" 6 | "encoding/json" 7 | "io" 8 | "io/ioutil" 9 | "strconv" 10 | 11 | "github.com/cespare/xxhash/v2" 12 | ) 13 | 14 | // Writer writes gzip data into suitable stream or returns 0, nil. 15 | type Writer interface { 16 | GzipWrite(d []byte) (int, error) 17 | } 18 | 19 | // JSONContainer contains compressed JSON. 20 | type JSONContainer struct { 21 | gz []byte 22 | hash string 23 | } 24 | 25 | // WriteCompressedBytes writes compressed bytes to response. 26 | // 27 | // Bytes are unpacked if response writer does not support direct gzip writing. 28 | func WriteCompressedBytes(compressed []byte, w io.Writer) (int, error) { 29 | if gw, ok := w.(Writer); ok { 30 | n, err := gw.GzipWrite(compressed) 31 | if n != 0 { 32 | return n, err 33 | } 34 | } 35 | 36 | // Decompress bytes before writing into not instrumented response writer. 37 | gr, err := gzip.NewReader(bytes.NewReader(compressed)) 38 | if err != nil { 39 | return 0, err 40 | } 41 | 42 | n, err := io.Copy(w, gr) //nolint:gosec // The origin of compressed data supposed to be app itself, safe to copy. 43 | 44 | return int(n), err 45 | } 46 | 47 | // UnpackJSON unmarshals data from JSON container into a Go value. 48 | func (jc JSONContainer) UnpackJSON(v interface{}) error { 49 | return UnmarshalJSON(jc.gz, v) 50 | } 51 | 52 | // PackJSON puts Go value in JSON container. 53 | func (jc *JSONContainer) PackJSON(v interface{}) error { 54 | res, err := MarshalJSON(v) 55 | if err != nil { 56 | return err 57 | } 58 | 59 | jc.gz = res 60 | jc.hash = strconv.FormatUint(xxhash.Sum64(res), 36) 61 | 62 | return nil 63 | } 64 | 65 | // GzipCompressedJSON returns JSON compressed with gzip. 66 | func (jc JSONContainer) GzipCompressedJSON() []byte { 67 | return jc.gz 68 | } 69 | 70 | // MarshalJSON returns uncompressed JSON. 71 | func (jc JSONContainer) MarshalJSON() (j []byte, err error) { 72 | b := bytes.NewReader(jc.gz) 73 | 74 | r, err := gzip.NewReader(b) 75 | if err != nil { 76 | return nil, err 77 | } 78 | 79 | defer func() { 80 | clErr := r.Close() 81 | if err == nil && clErr != nil { 82 | err = clErr 83 | } 84 | }() 85 | 86 | return ioutil.ReadAll(r) 87 | } 88 | 89 | // ETag returns hash of compressed bytes. 90 | func (jc JSONContainer) ETag() string { 91 | return jc.hash 92 | } 93 | 94 | // MarshalJSON encodes Go value as JSON and compresses result with gzip. 95 | func MarshalJSON(v interface{}) ([]byte, error) { 96 | b := bytes.Buffer{} 97 | w := gzip.NewWriter(&b) 98 | 99 | enc := json.NewEncoder(w) 100 | 101 | err := enc.Encode(v) 102 | if err != nil { 103 | return nil, err 104 | } 105 | 106 | err = w.Close() 107 | if err != nil { 108 | return nil, err 109 | } 110 | 111 | // Copying result slice to reduce dynamic capacity. 112 | res := make([]byte, len(b.Bytes())) 113 | copy(res, b.Bytes()) 114 | 115 | return res, nil 116 | } 117 | 118 | // UnmarshalJSON decodes compressed JSON bytes into a Go value. 119 | func UnmarshalJSON(data []byte, v interface{}) error { 120 | b := bytes.NewReader(data) 121 | 122 | r, err := gzip.NewReader(b) 123 | if err != nil { 124 | return err 125 | } 126 | 127 | dec := json.NewDecoder(r) 128 | 129 | err = dec.Decode(v) 130 | if err != nil { 131 | return err 132 | } 133 | 134 | return r.Close() 135 | } 136 | 137 | // JSONWriteTo writes JSON payload to writer. 138 | func (jc JSONContainer) JSONWriteTo(w io.Writer) (int, error) { 139 | return WriteCompressedBytes(jc.gz, w) 140 | } 141 | -------------------------------------------------------------------------------- /gzip/container_test.go: -------------------------------------------------------------------------------- 1 | package gzip_test 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | "github.com/swaggest/assertjson" 13 | "github.com/swaggest/rest/gzip" 14 | "github.com/swaggest/rest/nethttp" 15 | "github.com/swaggest/rest/response" 16 | gzip2 "github.com/swaggest/rest/response/gzip" 17 | "github.com/swaggest/usecase" 18 | ) 19 | 20 | func TestWriteJSON(t *testing.T) { 21 | v := make([]string, 0, 100) 22 | 23 | for i := 0; i < 100; i++ { 24 | v = append(v, "Quis autem vel eum iure reprehenderit, qui in ea voluptate velit esse, "+ 25 | "quam nihil molestiae consequatur, vel illum, qui dolorem eum fugiat, quo voluptas nulla pariatur?") 26 | } 27 | 28 | cont := gzip.JSONContainer{} 29 | require.NoError(t, cont.PackJSON(v)) 30 | 31 | var vv []string 32 | 33 | require.NoError(t, cont.UnpackJSON(&vv)) 34 | assert.Equal(t, v, vv) 35 | 36 | cj, err := json.Marshal(cont) 37 | require.NoError(t, err) 38 | 39 | vj, err := json.Marshal(v) 40 | require.NoError(t, err) 41 | 42 | assertjson.Equal(t, cj, vj) 43 | 44 | u := struct { 45 | usecase.Interactor 46 | usecase.WithOutput 47 | }{} 48 | 49 | var ur interface{} = cont 50 | 51 | u.Output = new(interface{}) 52 | u.Interactor = usecase.Interact(func(_ context.Context, _, output interface{}) error { 53 | *output.(*interface{}) = ur 54 | 55 | return nil 56 | }) 57 | 58 | h := nethttp.NewHandler(u) 59 | h.SetResponseEncoder(&response.Encoder{}) 60 | 61 | r, err := http.NewRequest(http.MethodGet, "/", nil) 62 | require.NoError(t, err) 63 | 64 | w := httptest.NewRecorder() 65 | 66 | r.Header.Set("Accept-Encoding", "deflate, gzip") 67 | gzip2.Middleware(h).ServeHTTP(w, r) 68 | 69 | assert.Equal(t, http.StatusOK, w.Code) 70 | assert.Equal(t, "gzip", w.Header().Get("Content-Encoding")) 71 | assert.Equal(t, "1ofolk6sr5j4r", w.Header().Get("Etag")) 72 | assert.Equal(t, cont.GzipCompressedJSON(), w.Body.Bytes()) 73 | 74 | w = httptest.NewRecorder() 75 | 76 | r.Header.Del("Accept-Encoding") 77 | gzip2.Middleware(h).ServeHTTP(w, r) 78 | 79 | assert.Equal(t, http.StatusOK, w.Code) 80 | assert.Equal(t, "", w.Header().Get("Content-Encoding")) 81 | assert.Equal(t, "1ofolk6sr5j4r", w.Header().Get("Etag")) 82 | assert.Equal(t, append(vj, '\n'), w.Body.Bytes()) 83 | 84 | w = httptest.NewRecorder() 85 | ur = v 86 | 87 | r.Header.Set("Accept-Encoding", "deflate, gzip") 88 | gzip2.Middleware(h).ServeHTTP(w, r) 89 | 90 | assert.Equal(t, http.StatusOK, w.Code) 91 | assert.Equal(t, "gzip", w.Header().Get("Content-Encoding")) 92 | assert.Equal(t, "", w.Header().Get("Etag")) 93 | } 94 | -------------------------------------------------------------------------------- /gzip/doc.go: -------------------------------------------------------------------------------- 1 | // Package gzip provides pre-compressed data container. 2 | package gzip 3 | -------------------------------------------------------------------------------- /nethttp/doc.go: -------------------------------------------------------------------------------- 1 | // Package nethttp provides instrumentation for net/http. 2 | package nethttp 3 | -------------------------------------------------------------------------------- /nethttp/example_test.go: -------------------------------------------------------------------------------- 1 | package nethttp_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/swaggest/assertjson" 9 | oapi "github.com/swaggest/openapi-go" 10 | "github.com/swaggest/openapi-go/openapi3" 11 | "github.com/swaggest/rest/nethttp" 12 | "github.com/swaggest/rest/web" 13 | "github.com/swaggest/usecase" 14 | ) 15 | 16 | func ExampleSecurityMiddleware() { 17 | // Create router. 18 | s := web.NewService(openapi3.NewReflector()) 19 | 20 | // Configure an actual security middleware. 21 | serviceTokenAuth := func(h http.Handler) http.Handler { 22 | return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 23 | if req.Header.Get("Authorization") != "" { 24 | http.Error(w, "Authentication failed.", http.StatusUnauthorized) 25 | 26 | return 27 | } 28 | 29 | h.ServeHTTP(w, req) 30 | }) 31 | } 32 | 33 | // Configure documentation middleware to describe actual security middleware. 34 | serviceTokenDoc := nethttp.APIKeySecurityMiddleware(s.OpenAPICollector, 35 | "serviceToken", "Authorization", oapi.InHeader, "Service token.") 36 | 37 | u := usecase.NewIOI(nil, nil, func(ctx context.Context, input, output interface{}) error { 38 | // Do something. 39 | return nil 40 | }) 41 | 42 | // Add use case handler to router with security middleware. 43 | s. 44 | With(serviceTokenAuth, serviceTokenDoc). // Apply a pair of middlewares: actual security and documentation. 45 | Method(http.MethodGet, "/foo", nethttp.NewHandler(u)) 46 | 47 | schema, _ := assertjson.MarshalIndentCompact(s.OpenAPISchema(), "", " ", 120) 48 | fmt.Println(string(schema)) 49 | 50 | // Output: 51 | // { 52 | // "openapi":"3.0.3","info":{"title":"","version":""}, 53 | // "paths":{ 54 | // "/foo":{ 55 | // "get":{ 56 | // "summary":"Example Security Middleware","operationId":"rest/nethttp_test.ExampleSecurityMiddleware", 57 | // "responses":{ 58 | // "204":{"description":"No Content"}, 59 | // "401":{ 60 | // "description":"Unauthorized", 61 | // "content":{"application/json":{"schema":{"$ref":"#/components/schemas/RestErrResponse"}}} 62 | // } 63 | // }, 64 | // "security":[{"serviceToken":[]}] 65 | // } 66 | // } 67 | // }, 68 | // "components":{ 69 | // "schemas":{ 70 | // "RestErrResponse":{ 71 | // "type":"object", 72 | // "properties":{ 73 | // "code":{"type":"integer","description":"Application-specific error code."}, 74 | // "context":{"type":"object","additionalProperties":{},"description":"Application context."}, 75 | // "error":{"type":"string","description":"Error message."},"status":{"type":"string","description":"Status text."} 76 | // } 77 | // } 78 | // }, 79 | // "securitySchemes":{"serviceToken":{"type":"apiKey","name":"Authorization","in":"header","description":"Service token."}} 80 | // } 81 | // } 82 | } 83 | -------------------------------------------------------------------------------- /nethttp/openapi_test.go: -------------------------------------------------------------------------------- 1 | package nethttp_test 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | "github.com/swaggest/assertjson" 11 | oapi "github.com/swaggest/openapi-go" 12 | "github.com/swaggest/rest/nethttp" 13 | "github.com/swaggest/rest/openapi" 14 | "github.com/swaggest/usecase" 15 | ) 16 | 17 | func TestOpenAPIMiddleware(t *testing.T) { 18 | u := &struct { 19 | usecase.Interactor 20 | usecase.WithInput 21 | usecase.WithOutput 22 | }{} 23 | 24 | u.Input = new(Input) 25 | u.Output = new(struct { 26 | Value string `json:"val"` 27 | Header int 28 | }) 29 | u.Interactor = usecase.Interact(func(_ context.Context, input, _ interface{}) error { 30 | in, ok := input.(*Input) 31 | assert.True(t, ok) 32 | assert.Equal(t, 123, in.ID) 33 | 34 | return nil 35 | }) 36 | 37 | uh := nethttp.NewHandler(u, 38 | nethttp.SuccessfulResponseContentType("application/vnd.ms-excel"), 39 | nethttp.RequestMapping(new(struct { 40 | ID int `query:"ident"` 41 | })), 42 | nethttp.ResponseHeaderMapping(new(struct { 43 | Header int `header:"X-Hd"` 44 | })), 45 | nethttp.AnnotateOpenAPIOperation(func(oc oapi.OperationContext) error { 46 | oc.SetDescription("Hello!") 47 | 48 | return nil 49 | }), 50 | ) 51 | 52 | c := openapi.Collector{} 53 | 54 | ws := []func(handler http.Handler) http.Handler{ 55 | nethttp.OpenAPIMiddleware(&c), 56 | nethttp.HTTPBasicSecurityMiddleware(&c, "admin", "Admin Area."), 57 | nethttp.HTTPBearerSecurityMiddleware(&c, "api", "API Security.", "JWT", 58 | nethttp.SecurityResponse(new(struct { 59 | Error string `json:"error"` 60 | }), http.StatusForbidden)), 61 | nethttp.HandlerWithRouteMiddleware(http.MethodGet, "/test"), 62 | } 63 | 64 | _ = nethttp.WrapHandler(uh, ws...) 65 | 66 | for i, w := range ws { 67 | assert.True(t, nethttp.MiddlewareIsWrapper(w), i) 68 | } 69 | 70 | sp, err := assertjson.MarshalIndentCompact(c.SpecSchema(), "", " ", 100) 71 | require.NoError(t, err) 72 | 73 | assertjson.Equal(t, []byte(`{ 74 | "openapi":"3.0.3","info":{"title":"","version":""}, 75 | "paths":{ 76 | "/test":{ 77 | "get":{ 78 | "description":"Hello!","parameters":[{"name":"ident","in":"query","schema":{"type":"integer"}}], 79 | "responses":{ 80 | "200":{ 81 | "description":"OK","headers":{"X-Hd":{"style":"simple","schema":{"type":"integer"}}}, 82 | "content":{ 83 | "application/vnd.ms-excel":{"schema":{"type":"object","properties":{"val":{"type":"string"}}}} 84 | } 85 | }, 86 | "401":{ 87 | "description":"Unauthorized", 88 | "content":{"application/json":{"schema":{"$ref":"#/components/schemas/RestErrResponse"}}} 89 | }, 90 | "403":{ 91 | "description":"Forbidden", 92 | "content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}}}}} 93 | } 94 | }, 95 | "security":[{"api":[]},{"admin":[]}] 96 | } 97 | } 98 | }, 99 | "components":{ 100 | "schemas":{ 101 | "RestErrResponse":{ 102 | "type":"object", 103 | "properties":{ 104 | "code":{"type":"integer","description":"Application-specific error code."}, 105 | "context":{"type":"object","additionalProperties":{},"description":"Application context."}, 106 | "error":{"type":"string","description":"Error message."}, 107 | "status":{"type":"string","description":"Status text."} 108 | } 109 | } 110 | }, 111 | "securitySchemes":{ 112 | "admin":{"type":"http","scheme":"basic","description":"Admin Area."}, 113 | "api":{"type":"http","scheme":"bearer","bearerFormat":"JWT","description":"API Security."} 114 | } 115 | } 116 | }`), sp, string(sp)) 117 | } 118 | -------------------------------------------------------------------------------- /nethttp/options_test.go: -------------------------------------------------------------------------------- 1 | package nethttp_test 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/require" 9 | "github.com/swaggest/assertjson" 10 | "github.com/swaggest/openapi-go/openapi3" 11 | "github.com/swaggest/rest/nethttp" 12 | "github.com/swaggest/rest/web" 13 | "github.com/swaggest/usecase" 14 | ) 15 | 16 | func TestRequestBodyContent(t *testing.T) { 17 | h := &nethttp.Handler{} 18 | 19 | r := openapi3.NewReflector() 20 | oc, err := r.NewOperationContext(http.MethodPost, "/") 21 | require.NoError(t, err) 22 | 23 | nethttp.RequestBodyContent("text/plain")(h) 24 | require.Len(t, h.OpenAPIAnnotations, 1) 25 | require.NoError(t, h.OpenAPIAnnotations[0](oc)) 26 | 27 | require.NoError(t, r.AddOperation(oc)) 28 | 29 | assertjson.EqMarshal(t, `{ 30 | "openapi":"3.0.3","info":{"title":"","version":""}, 31 | "paths":{ 32 | "/":{ 33 | "post":{ 34 | "requestBody":{"content":{"text/plain":{"schema":{"type":"string"}}}}, 35 | "responses":{"204":{"description":"No Content"}} 36 | } 37 | } 38 | } 39 | }`, r.SpecSchema()) 40 | } 41 | 42 | func TestRequestBodyContent_webService(t *testing.T) { 43 | s := web.NewService(openapi3.NewReflector()) 44 | 45 | u := usecase.NewIOI(new(string), nil, func(_ context.Context, _, _ interface{}) error { 46 | return nil 47 | }) 48 | 49 | s.Post("/text-req-body", u, nethttp.RequestBodyContent("text/csv")) 50 | 51 | assertjson.EqMarshal(t, `{ 52 | "openapi":"3.0.3","info":{"title":"","version":""}, 53 | "paths":{ 54 | "/text-req-body":{ 55 | "post":{ 56 | "summary":"Test Request Body Content _ web Service", 57 | "operationId":"rest/nethttp_test.TestRequestBodyContent_webService", 58 | "requestBody":{"content":{"text/csv":{"schema":{"type":"string"}}}}, 59 | "responses":{"204":{"description":"No Content"}} 60 | } 61 | } 62 | } 63 | }`, s.OpenAPISchema()) 64 | } 65 | -------------------------------------------------------------------------------- /nethttp/usecase.go: -------------------------------------------------------------------------------- 1 | package nethttp 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/swaggest/usecase" 8 | ) 9 | 10 | // UseCaseMiddlewares applies use case middlewares to Handler. 11 | func UseCaseMiddlewares(mw ...usecase.Middleware) func(http.Handler) http.Handler { 12 | return func(handler http.Handler) http.Handler { 13 | if IsWrapperChecker(handler) { 14 | return handler 15 | } 16 | 17 | var uh *Handler 18 | if !HandlerAs(handler, &uh) { 19 | return handler 20 | } 21 | 22 | u := uh.UseCase() 23 | fu := usecase.Wrap(u, usecase.MiddlewareFunc(func(_ usecase.Interactor) usecase.Interactor { 24 | return usecase.Interact(func(ctx context.Context, _, _ interface{}) error { 25 | err, ok := ctx.Value(decodeErrCtxKey{}).(error) 26 | if ok { 27 | return err 28 | } 29 | 30 | return nil 31 | }) 32 | })) 33 | 34 | uh.SetUseCase(usecase.Wrap(u, mw...)) 35 | uh.failingUseCase = usecase.Wrap(fu, mw...) 36 | 37 | return handler 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /nethttp/wrap.go: -------------------------------------------------------------------------------- 1 | package nethttp 2 | 3 | import ( 4 | "net/http" 5 | "reflect" 6 | "runtime" 7 | ) 8 | 9 | // WrapHandler wraps http.Handler with an unwrappable middleware. 10 | // 11 | // Wrapping order is reversed, e.g. if you call WrapHandler(h, mw1, mw2, mw3) middlewares will be 12 | // invoked in order of mw1(mw2(mw3(h))), mw3 first and mw1 last. So that request processing is first 13 | // affected by mw1. 14 | func WrapHandler(h http.Handler, mw ...func(http.Handler) http.Handler) http.Handler { 15 | for i := len(mw) - 1; i >= 0; i-- { 16 | w := mw[i](h) 17 | if w == nil { 18 | panic("nil handler returned from middleware: " + runtime.FuncForPC(reflect.ValueOf(mw[i]).Pointer()).Name()) 19 | } 20 | 21 | fp := reflect.ValueOf(mw[i]).Pointer() 22 | mwName := runtime.FuncForPC(fp).Name() 23 | 24 | h = &wrappedHandler{ 25 | Handler: w, 26 | wrapped: h, 27 | mwName: mwName, 28 | } 29 | } 30 | 31 | return h 32 | } 33 | 34 | // HandlerAs finds the first http.Handler in http.Handler's chain that matches target, and if so, sets 35 | // target to that http.Handler value and returns true. 36 | // 37 | // An http.Handler matches target if the http.Handler's concrete value is assignable to the value 38 | // pointed to by target. 39 | // 40 | // HandlerAs will panic if target is not a non-nil pointer to either a type that implements 41 | // http.Handler, or to any interface type. 42 | func HandlerAs(handler http.Handler, target interface{}) bool { 43 | if target == nil { 44 | panic("target cannot be nil") 45 | } 46 | 47 | val := reflect.ValueOf(target) 48 | typ := val.Type() 49 | 50 | if typ.Kind() != reflect.Ptr || val.IsNil() { 51 | panic("target must be a non-nil pointer") 52 | } 53 | 54 | if e := typ.Elem(); e.Kind() != reflect.Interface && !e.Implements(handlerType) { 55 | panic("*target must be interface or implement http.Handler") 56 | } 57 | 58 | targetType := typ.Elem() 59 | 60 | for { 61 | wrap, isWrap := handler.(*wrappedHandler) 62 | 63 | if isWrap { 64 | handler = wrap.Handler 65 | } 66 | 67 | if handler == nil { 68 | break 69 | } 70 | 71 | if reflect.TypeOf(handler).AssignableTo(targetType) { 72 | val.Elem().Set(reflect.ValueOf(handler)) 73 | 74 | return true 75 | } 76 | 77 | if !isWrap { 78 | break 79 | } 80 | 81 | handler = wrap.wrapped 82 | } 83 | 84 | return false 85 | } 86 | 87 | var handlerType = reflect.TypeOf((*http.Handler)(nil)).Elem() 88 | 89 | type wrappedHandler struct { 90 | http.Handler 91 | wrapped http.Handler 92 | mwName string 93 | } 94 | 95 | func (w *wrappedHandler) String() string { 96 | if h, ok := w.wrapped.(*wrappedHandler); ok { 97 | return w.mwName + "(" + h.String() + ")" 98 | } 99 | 100 | return "handler" 101 | } 102 | -------------------------------------------------------------------------------- /nethttp/wrap_test.go: -------------------------------------------------------------------------------- 1 | package nethttp_test 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/swaggest/rest/nethttp" 9 | ) 10 | 11 | func TestWrapHandler(t *testing.T) { 12 | var flow []string 13 | 14 | h := nethttp.WrapHandler( 15 | http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { 16 | flow = append(flow, "handler") 17 | }), 18 | func(handler http.Handler) http.Handler { 19 | flow = append(flow, "mw1 registered") 20 | 21 | return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { 22 | flow = append(flow, "mw1 before") 23 | 24 | handler.ServeHTTP(writer, request) 25 | 26 | flow = append(flow, "mw1 after") 27 | }) 28 | }, 29 | func(handler http.Handler) http.Handler { 30 | flow = append(flow, "mw2 registered") 31 | 32 | return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { 33 | flow = append(flow, "mw2 before") 34 | 35 | handler.ServeHTTP(writer, request) 36 | 37 | flow = append(flow, "mw2 after") 38 | }) 39 | }, 40 | func(handler http.Handler) http.Handler { 41 | flow = append(flow, "mw3 registered") 42 | 43 | return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { 44 | flow = append(flow, "mw3 before") 45 | 46 | handler.ServeHTTP(writer, request) 47 | 48 | flow = append(flow, "mw3 after") 49 | }) 50 | }, 51 | ) 52 | 53 | h.ServeHTTP(nil, nil) 54 | 55 | assert.Equal(t, []string{ 56 | "mw3 registered", "mw2 registered", "mw1 registered", 57 | "mw1 before", "mw2 before", "mw3 before", 58 | "handler", 59 | "mw3 after", "mw2 after", "mw1 after", 60 | }, flow) 61 | } 62 | 63 | func TestHandlerAs_nil(t *testing.T) { 64 | var uh *nethttp.Handler 65 | 66 | assert.False(t, nethttp.HandlerAs(nil, &uh)) 67 | } 68 | -------------------------------------------------------------------------------- /nethttp/wrapper.go: -------------------------------------------------------------------------------- 1 | package nethttp 2 | 3 | import "net/http" 4 | 5 | type wrapperChecker struct { 6 | found bool 7 | } 8 | 9 | func (*wrapperChecker) ServeHTTP(_ http.ResponseWriter, _ *http.Request) {} 10 | 11 | // IsWrapperChecker is a hack to mark middleware as a handler wrapper. 12 | // See chirouter.Wrapper Wrap() documentation for more details on the difference. 13 | // 14 | // Wrappers should invoke the check and do early return if it succeeds. 15 | func IsWrapperChecker(h http.Handler) bool { 16 | if wm, ok := h.(*wrapperChecker); ok { 17 | wm.found = true 18 | 19 | return true 20 | } 21 | 22 | return false 23 | } 24 | 25 | // MiddlewareIsWrapper is a hack to detect whether middleware is a handler wrapper. 26 | // See chirouter.Wrapper Wrap() documentation for more details on the difference. 27 | func MiddlewareIsWrapper(mw func(h http.Handler) http.Handler) bool { 28 | wm := &wrapperChecker{} 29 | mw(wm) 30 | 31 | return wm.found 32 | } 33 | -------------------------------------------------------------------------------- /nethttp/wrapper_test.go: -------------------------------------------------------------------------------- 1 | package nethttp_test 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/swaggest/rest/nethttp" 9 | ) 10 | 11 | func TestMiddlewareIsWrapper(t *testing.T) { 12 | wrapper := func(handler http.Handler) http.Handler { 13 | if nethttp.IsWrapperChecker(handler) { 14 | return handler 15 | } 16 | 17 | return handler 18 | } 19 | 20 | notWrapper := func(handler http.Handler) http.Handler { 21 | return handler 22 | } 23 | 24 | assert.True(t, nethttp.MiddlewareIsWrapper(wrapper)) 25 | assert.False(t, nethttp.MiddlewareIsWrapper(notWrapper)) 26 | } 27 | -------------------------------------------------------------------------------- /request.go: -------------------------------------------------------------------------------- 1 | package rest 2 | 3 | // ParamIn defines parameter location. 4 | type ParamIn string 5 | 6 | const ( 7 | // ParamInPath indicates path parameters, such as `/users/{id}`. 8 | ParamInPath = ParamIn("path") 9 | 10 | // ParamInQuery indicates query parameters, such as `/users?page=10`. 11 | ParamInQuery = ParamIn("query") 12 | 13 | // ParamInBody indicates body value, such as `{"id": 10}`. 14 | ParamInBody = ParamIn("body") 15 | 16 | // ParamInFormData indicates body form parameters. 17 | ParamInFormData = ParamIn("formData") 18 | 19 | // ParamInCookie indicates cookie parameters, which are passed ParamIn the `Cookie` header, 20 | // such as `Cookie: debug=0; gdpr=2`. 21 | ParamInCookie = ParamIn("cookie") 22 | 23 | // ParamInHeader indicates header parameters, such as `X-Header: value`. 24 | ParamInHeader = ParamIn("header") 25 | ) 26 | 27 | // RequestMapping describes how decoded request should be applied to container struct. 28 | // 29 | // It is defined as a map by parameter location. 30 | // Each item is a map with struct field name as key and decoded field name as value. 31 | // 32 | // Example: 33 | // 34 | // map[rest.ParamIn]map[string]string{rest.ParamInQuery:map[string]string{"ID": "id", "FirstName": "first-name"}} 35 | type RequestMapping map[ParamIn]map[string]string 36 | 37 | // RequestErrors is a list of validation or decoding errors. 38 | // 39 | // Key is field position (e.g. "path:id" or "body"), value is a list of issues with the field. 40 | type RequestErrors map[string][]string 41 | 42 | // Error returns error message. 43 | func (re RequestErrors) Error() string { 44 | return "bad request" 45 | } 46 | 47 | // Fields returns request errors by field location and name. 48 | func (re RequestErrors) Fields() map[string]interface{} { 49 | res := make(map[string]interface{}, len(re)) 50 | 51 | for k, v := range re { 52 | res[k] = v 53 | } 54 | 55 | return res 56 | } 57 | -------------------------------------------------------------------------------- /request/doc.go: -------------------------------------------------------------------------------- 1 | // Package request implements reflection-based net/http request decoder. 2 | package request 3 | -------------------------------------------------------------------------------- /request/error.go: -------------------------------------------------------------------------------- 1 | package request 2 | 3 | import "errors" 4 | 5 | // These errors may be returned on request decoding failure. 6 | var ( 7 | ErrJSONExpected = errors.New("request with application/json content type expected") 8 | ErrMissingRequestBody = errors.New("missing request body") 9 | ErrMissingRequiredFile = errors.New("missing required file") 10 | ) 11 | -------------------------------------------------------------------------------- /request/example_test.go: -------------------------------------------------------------------------------- 1 | package request_test 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/swaggest/rest/request" 8 | ) 9 | 10 | func ExampleDecoder_Decode() { 11 | type MyRequest struct { 12 | Foo int `header:"X-Foo"` 13 | Bar string `formData:"bar"` 14 | Baz bool `query:"baz"` 15 | } 16 | 17 | // A decoder for particular structure, can be reused for multiple HTTP requests. 18 | myDecoder := request.NewDecoderFactory().MakeDecoder(http.MethodPost, new(MyRequest), nil) 19 | 20 | // Request and response writer from ServeHTTP. 21 | var ( 22 | rw http.ResponseWriter 23 | req *http.Request 24 | ) 25 | 26 | // This code would presumably live in ServeHTTP. 27 | var myReq MyRequest 28 | 29 | if err := myDecoder.Decode(req, &myReq, nil); err != nil { 30 | http.Error(rw, err.Error(), http.StatusBadRequest) 31 | } 32 | } 33 | 34 | func ExampleEmbeddedSetter_Request() { 35 | type MyRequest struct { 36 | request.EmbeddedSetter 37 | 38 | Foo int `header:"X-Foo"` 39 | Bar string `formData:"bar"` 40 | Baz bool `query:"baz"` 41 | } 42 | 43 | // A decoder for particular structure, can be reused for multiple HTTP requests. 44 | myDecoder := request.NewDecoderFactory().MakeDecoder(http.MethodPost, new(MyRequest), nil) 45 | 46 | // Request and response writer from ServeHTTP. 47 | var ( 48 | rw http.ResponseWriter 49 | req *http.Request 50 | ) 51 | 52 | // This code would presumably live in ServeHTTP. 53 | var myReq MyRequest 54 | 55 | if err := myDecoder.Decode(req, &myReq, nil); err != nil { 56 | http.Error(rw, err.Error(), http.StatusBadRequest) 57 | } 58 | 59 | // Access data from raw request. 60 | fmt.Println("Remote Addr:", myReq.Request().RemoteAddr) 61 | } 62 | -------------------------------------------------------------------------------- /request/file.go: -------------------------------------------------------------------------------- 1 | package request 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "mime/multipart" 7 | "net/http" 8 | "reflect" 9 | 10 | "github.com/swaggest/rest" 11 | ) 12 | 13 | var ( 14 | multipartFileType = reflect.TypeOf((*multipart.File)(nil)).Elem() 15 | multipartFilesType = reflect.TypeOf(([]multipart.File)(nil)) 16 | multipartFileHeaderType = reflect.TypeOf((*multipart.FileHeader)(nil)) 17 | multipartFileHeadersType = reflect.TypeOf(([]*multipart.FileHeader)(nil)) 18 | ) 19 | 20 | func decodeFiles(r *http.Request, input interface{}, _ rest.Validator) error { 21 | v := reflect.ValueOf(input) 22 | 23 | return decodeFilesInStruct(r, v) 24 | } 25 | 26 | func decodeFilesInStruct(r *http.Request, v reflect.Value) error { 27 | for v.Kind() == reflect.Ptr { 28 | v = v.Elem() 29 | } 30 | 31 | if v.Kind() != reflect.Struct { 32 | return nil 33 | } 34 | 35 | t := v.Type() 36 | 37 | for i := 0; i < t.NumField(); i++ { 38 | field := t.Field(i) 39 | 40 | if field.Type == multipartFileType || field.Type == multipartFileHeaderType || 41 | field.Type == multipartFilesType || field.Type == multipartFileHeadersType { 42 | err := setFile(r, field, v.Field(i)) 43 | if err != nil { 44 | return err 45 | } 46 | 47 | continue 48 | } 49 | 50 | if field.Anonymous { 51 | if err := decodeFilesInStruct(r, v.Field(i)); err != nil { 52 | return err 53 | } 54 | } 55 | } 56 | 57 | return nil 58 | } 59 | 60 | func setFile(r *http.Request, field reflect.StructField, v reflect.Value) error { 61 | name := "" 62 | if tag := field.Tag.Get(fileTag); tag != "" && tag != "-" { 63 | name = tag 64 | } else if tag := field.Tag.Get(formDataTag); tag != "" && tag != "-" { 65 | name = tag 66 | } 67 | 68 | if name == "" { 69 | return nil 70 | } 71 | 72 | file, header, err := r.FormFile(name) 73 | if err != nil { 74 | if errors.Is(err, http.ErrMissingFile) { 75 | if field.Tag.Get("required") == "true" { 76 | return fmt.Errorf("%w: %q", ErrMissingRequiredFile, name) 77 | } 78 | 79 | return nil 80 | } 81 | 82 | return fmt.Errorf("failed to get file %q from request: %w", name, err) 83 | } 84 | 85 | if field.Type == multipartFileType { 86 | v.Set(reflect.ValueOf(file)) 87 | } 88 | 89 | if field.Type == multipartFileHeaderType { 90 | v.Set(reflect.ValueOf(header)) 91 | } 92 | 93 | if field.Type == multipartFilesType { 94 | res := make([]multipart.File, 0, len(r.MultipartForm.File[name])) 95 | 96 | for _, h := range r.MultipartForm.File[name] { 97 | f, err := h.Open() 98 | if err != nil { 99 | return fmt.Errorf("failed to open uploaded file %s (%s): %w", name, h.Filename, err) 100 | } 101 | 102 | res = append(res, f) 103 | } 104 | 105 | v.Set(reflect.ValueOf(res)) 106 | } 107 | 108 | if field.Type == multipartFileHeadersType { 109 | v.Set(reflect.ValueOf(r.MultipartForm.File[name])) 110 | } 111 | 112 | return nil 113 | } 114 | -------------------------------------------------------------------------------- /request/jsonbody.go: -------------------------------------------------------------------------------- 1 | package request 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "strings" 10 | "sync" 11 | 12 | "github.com/swaggest/rest" 13 | ) 14 | 15 | var bufPool = sync.Pool{ 16 | New: func() interface{} { 17 | return bytes.NewBuffer(nil) 18 | }, 19 | } 20 | 21 | func readJSON(rd io.Reader, v interface{}) error { 22 | d := json.NewDecoder(rd) 23 | 24 | return d.Decode(v) 25 | } 26 | 27 | func decodeJSONBody(readJSON func(rd io.Reader, v interface{}) error, tolerateFormData bool) valueDecoderFunc { 28 | return func(r *http.Request, input interface{}, validator rest.Validator) error { 29 | if r.ContentLength == 0 { 30 | return ErrMissingRequestBody 31 | } 32 | 33 | if ret, err := checkJSONBodyContentType(r.Header.Get("Content-Type"), tolerateFormData); err != nil { 34 | return err 35 | } else if ret { 36 | return nil 37 | } 38 | 39 | var ( 40 | rd io.Reader = r.Body 41 | b *bytes.Buffer 42 | ) 43 | 44 | validate := validator != nil && validator.HasConstraints(rest.ParamInBody) 45 | 46 | if validate { 47 | b = bufPool.Get().(*bytes.Buffer) //nolint:errcheck // bufPool is configured to provide *bytes.Buffer. 48 | defer bufPool.Put(b) 49 | 50 | b.Reset() 51 | rd = io.TeeReader(r.Body, b) 52 | } 53 | 54 | err := readJSON(rd, &input) 55 | if err != nil { 56 | return fmt.Errorf("failed to decode json: %w", err) 57 | } 58 | 59 | if validator != nil && validate { 60 | err = validator.ValidateJSONBody(b.Bytes()) 61 | if err != nil { 62 | return err 63 | } 64 | } 65 | 66 | return nil 67 | } 68 | } 69 | 70 | func checkJSONBodyContentType(contentType string, tolerateFormData bool) (ret bool, err error) { 71 | if contentType == "" { 72 | return false, nil 73 | } 74 | 75 | if len(contentType) < 16 || strings.ToLower(contentType[0:16]) != "application/json" { // allow 'application/json;charset=UTF-8' 76 | if tolerateFormData && (contentType == "application/x-www-form-urlencoded" || contentType == "multipart/form-data") { 77 | return true, nil 78 | } 79 | 80 | return true, fmt.Errorf("%w, received: %s", ErrJSONExpected, contentType) 81 | } 82 | 83 | return false, nil 84 | } 85 | -------------------------------------------------------------------------------- /request/middleware.go: -------------------------------------------------------------------------------- 1 | package request 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/swaggest/rest" 7 | "github.com/swaggest/rest/nethttp" 8 | "github.com/swaggest/usecase" 9 | ) 10 | 11 | type requestDecoderSetter interface { 12 | SetRequestDecoder(rd nethttp.RequestDecoder) 13 | } 14 | 15 | type requestMapping interface { 16 | RequestMapping() rest.RequestMapping 17 | } 18 | 19 | // DecoderMiddleware sets up request decoder in suitable handlers. 20 | func DecoderMiddleware(factory DecoderMaker) func(http.Handler) http.Handler { 21 | return func(handler http.Handler) http.Handler { 22 | if nethttp.IsWrapperChecker(handler) { 23 | return handler 24 | } 25 | 26 | var ( 27 | withRoute rest.HandlerWithRoute 28 | withUseCase rest.HandlerWithUseCase 29 | withRequestMapping requestMapping 30 | setRequestDecoder requestDecoderSetter 31 | useCaseWithInput usecase.HasInputPort 32 | ) 33 | 34 | if !nethttp.HandlerAs(handler, &setRequestDecoder) || 35 | !nethttp.HandlerAs(handler, &withUseCase) || 36 | !usecase.As(withUseCase.UseCase(), &useCaseWithInput) { 37 | return handler 38 | } 39 | 40 | var customMapping rest.RequestMapping 41 | if nethttp.HandlerAs(handler, &withRequestMapping) { 42 | customMapping = withRequestMapping.RequestMapping() 43 | } 44 | 45 | input := useCaseWithInput.InputPort() 46 | if input != nil { 47 | method := http.MethodPost // Default for handlers without method (for example NotFound handler). 48 | if nethttp.HandlerAs(handler, &withRoute) { 49 | method = withRoute.RouteMethod() 50 | } 51 | 52 | dec := factory.MakeDecoder(method, input, customMapping) 53 | setRequestDecoder.SetRequestDecoder(dec) 54 | } 55 | 56 | return handler 57 | } 58 | } 59 | 60 | type withRestHandler interface { 61 | RestHandler() *rest.HandlerTrait 62 | } 63 | 64 | // ValidatorMiddleware sets up request validator in suitable handlers. 65 | func ValidatorMiddleware(factory rest.RequestValidatorFactory) func(http.Handler) http.Handler { 66 | return func(handler http.Handler) http.Handler { 67 | if nethttp.IsWrapperChecker(handler) { 68 | return handler 69 | } 70 | 71 | var ( 72 | withRoute rest.HandlerWithRoute 73 | withUseCase rest.HandlerWithUseCase 74 | handlerTrait withRestHandler 75 | useCaseWithInput usecase.HasInputPort 76 | ) 77 | 78 | if !nethttp.HandlerAs(handler, &handlerTrait) || 79 | !nethttp.HandlerAs(handler, &withRoute) || 80 | !nethttp.HandlerAs(handler, &withUseCase) || 81 | !usecase.As(withUseCase.UseCase(), &useCaseWithInput) { 82 | return handler 83 | } 84 | 85 | rh := handlerTrait.RestHandler() 86 | 87 | rh.ReqValidator = factory.MakeRequestValidator( 88 | withRoute.RouteMethod(), useCaseWithInput.InputPort(), rh.ReqMapping) 89 | 90 | return handler 91 | } 92 | } 93 | 94 | var _ nethttp.RequestDecoder = DecoderFunc(nil) 95 | 96 | // DecoderFunc implements RequestDecoder with a func. 97 | type DecoderFunc func(r *http.Request, input interface{}, validator rest.Validator) error 98 | 99 | // Decode implements RequestDecoder. 100 | func (df DecoderFunc) Decode(r *http.Request, input interface{}, validator rest.Validator) error { 101 | return df(r, input, validator) 102 | } 103 | 104 | // DecoderMaker creates request decoder for particular structured Go input value. 105 | type DecoderMaker interface { 106 | MakeDecoder(method string, input interface{}, customMapping rest.RequestMapping) nethttp.RequestDecoder 107 | } 108 | -------------------------------------------------------------------------------- /request/reflect.go: -------------------------------------------------------------------------------- 1 | package request 2 | 3 | import ( 4 | "reflect" 5 | 6 | "github.com/swaggest/refl" 7 | ) 8 | 9 | // HasFileFields checks if the structure has fields to receive uploaded files. 10 | func hasFileFields(i interface{}, tagname string) bool { 11 | found := false 12 | 13 | refl.WalkTaggedFields(reflect.ValueOf(i), func(_ reflect.Value, sf reflect.StructField, _ string) { 14 | if sf.Type == multipartFileType || sf.Type == multipartFileHeaderType || 15 | sf.Type == multipartFilesType || sf.Type == multipartFileHeadersType { 16 | found = true 17 | 18 | return 19 | } 20 | }, tagname) 21 | 22 | return found 23 | } 24 | -------------------------------------------------------------------------------- /request_test.go: -------------------------------------------------------------------------------- 1 | package rest_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/swaggest/rest" 8 | ) 9 | 10 | func TestRequestErrors_Error(t *testing.T) { 11 | err := rest.RequestErrors{ 12 | "foo": []string{"bar"}, 13 | } 14 | 15 | assert.EqualError(t, err, "bad request") 16 | assert.Equal(t, map[string]interface{}{"foo": []string{"bar"}}, err.Fields()) 17 | } 18 | -------------------------------------------------------------------------------- /response.go: -------------------------------------------------------------------------------- 1 | package rest 2 | 3 | import "io" 4 | 5 | // ETagged exposes specific version of resource. 6 | type ETagged interface { 7 | ETag() string 8 | } 9 | 10 | // JSONWriterTo writes JSON payload. 11 | type JSONWriterTo interface { 12 | JSONWriteTo(w io.Writer) (int, error) 13 | } 14 | 15 | // OutputWithHTTPStatus exposes HTTP status code(s) for output. 16 | type OutputWithHTTPStatus interface { 17 | HTTPStatus() int 18 | ExpectedHTTPStatuses() []int 19 | } 20 | -------------------------------------------------------------------------------- /response/doc.go: -------------------------------------------------------------------------------- 1 | // Package response implements reflection-based net/http response encoder. 2 | package response 3 | -------------------------------------------------------------------------------- /response/gzip/doc.go: -------------------------------------------------------------------------------- 1 | // Package gzip provides http compression support. 2 | package gzip 3 | -------------------------------------------------------------------------------- /response/middleware.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/swaggest/rest" 7 | "github.com/swaggest/rest/nethttp" 8 | "github.com/swaggest/usecase" 9 | ) 10 | 11 | type responseEncoderSetter interface { 12 | SetResponseEncoder(responseWriter nethttp.ResponseEncoder) 13 | } 14 | 15 | // EncoderMiddleware instruments qualifying http.Handler with Encoder. 16 | func EncoderMiddleware(handler http.Handler) http.Handler { 17 | if nethttp.IsWrapperChecker(handler) { 18 | return handler 19 | } 20 | 21 | var ( 22 | withUseCase rest.HandlerWithUseCase 23 | setResponseEncoder responseEncoderSetter 24 | useCaseWithOutput usecase.HasOutputPort 25 | restHandler withRestHandler 26 | ) 27 | 28 | if !nethttp.HandlerAs(handler, &setResponseEncoder) { 29 | return handler 30 | } 31 | 32 | responseEncoder := Encoder{} 33 | 34 | if nethttp.HandlerAs(handler, &withUseCase) && 35 | nethttp.HandlerAs(handler, &restHandler) && 36 | usecase.As(withUseCase.UseCase(), &useCaseWithOutput) { 37 | responseEncoder.SetupOutput(useCaseWithOutput.OutputPort(), restHandler.RestHandler()) 38 | } 39 | 40 | setResponseEncoder.SetResponseEncoder(&responseEncoder) 41 | 42 | return handler 43 | } 44 | -------------------------------------------------------------------------------- /response/middleware_test.go: -------------------------------------------------------------------------------- 1 | package response_test 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | "github.com/swaggest/rest/nethttp" 12 | "github.com/swaggest/rest/response" 13 | "github.com/swaggest/usecase" 14 | ) 15 | 16 | func TestEncoderMiddleware(t *testing.T) { 17 | u := struct { 18 | usecase.Interactor 19 | usecase.WithOutput 20 | }{} 21 | 22 | type outputPort struct { 23 | Name string `header:"X-Name" json:"-"` 24 | Items []string `json:"items"` 25 | } 26 | 27 | u.Output = new(outputPort) 28 | u.Interactor = usecase.Interact(func(_ context.Context, _, output interface{}) error { 29 | output.(*outputPort).Name = "Jane" 30 | output.(*outputPort).Items = []string{"one", "two", "three"} 31 | 32 | return nil 33 | }) 34 | 35 | h := nethttp.NewHandler(u) 36 | 37 | w := httptest.NewRecorder() 38 | r, err := http.NewRequest(http.MethodGet, "/", nil) 39 | require.NoError(t, err) 40 | 41 | em := response.EncoderMiddleware 42 | em(h).ServeHTTP(w, r) 43 | assert.Equal(t, http.StatusOK, w.Code) 44 | assert.Equal(t, `{"items":["one","two","three"]}`+"\n", w.Body.String()) 45 | assert.Equal(t, "Jane", w.Header().Get("X-Name")) 46 | assert.True(t, nethttp.MiddlewareIsWrapper(em)) 47 | } 48 | -------------------------------------------------------------------------------- /response/validator.go: -------------------------------------------------------------------------------- 1 | package response 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/swaggest/rest" 7 | "github.com/swaggest/rest/nethttp" 8 | "github.com/swaggest/usecase" 9 | ) 10 | 11 | type withRestHandler interface { 12 | RestHandler() *rest.HandlerTrait 13 | } 14 | 15 | // ValidatorMiddleware sets up response validator in suitable handlers. 16 | func ValidatorMiddleware(factory rest.ResponseValidatorFactory) func(http.Handler) http.Handler { 17 | return func(handler http.Handler) http.Handler { 18 | if nethttp.IsWrapperChecker(handler) { 19 | return handler 20 | } 21 | 22 | var ( 23 | withUseCase rest.HandlerWithUseCase 24 | handlerTrait withRestHandler 25 | useCaseWithOutput usecase.HasOutputPort 26 | ) 27 | 28 | if !nethttp.HandlerAs(handler, &handlerTrait) || 29 | !nethttp.HandlerAs(handler, &withUseCase) || 30 | !usecase.As(withUseCase.UseCase(), &useCaseWithOutput) { 31 | return handler 32 | } 33 | 34 | rh := handlerTrait.RestHandler() 35 | 36 | statusCode := rh.SuccessStatus 37 | if statusCode == 0 { 38 | statusCode = http.StatusOK 39 | 40 | if rest.OutputHasNoContent(useCaseWithOutput.OutputPort()) { 41 | statusCode = http.StatusNoContent 42 | } 43 | } 44 | 45 | rh.RespValidator = factory.MakeResponseValidator( 46 | statusCode, rh.SuccessContentType, useCaseWithOutput.OutputPort(), rh.RespHeaderMapping, 47 | ) 48 | 49 | return handler 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /response/validator_test.go: -------------------------------------------------------------------------------- 1 | package response_test 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | "github.com/swaggest/rest/jsonschema" 12 | "github.com/swaggest/rest/nethttp" 13 | "github.com/swaggest/rest/openapi" 14 | "github.com/swaggest/rest/response" 15 | "github.com/swaggest/usecase" 16 | ) 17 | 18 | func TestValidatorMiddleware(t *testing.T) { 19 | u := struct { 20 | usecase.Interactor 21 | usecase.WithOutput 22 | }{} 23 | 24 | type outputPort struct { 25 | Name string `header:"X-Name" minLength:"3" json:"-"` 26 | Items []string `json:"items" minItems:"3"` 27 | } 28 | 29 | invalidOut := outputPort{ 30 | Name: "Ja", 31 | Items: []string{"one"}, 32 | } 33 | 34 | u.Output = new(outputPort) 35 | u.Interactor = usecase.Interact(func(_ context.Context, _, output interface{}) error { 36 | out, ok := output.(*outputPort) 37 | require.True(t, ok) 38 | 39 | *out = invalidOut 40 | 41 | return nil 42 | }) 43 | 44 | h := nethttp.NewHandler(u) 45 | 46 | apiSchema := &openapi.Collector{} 47 | validatorFactory := jsonschema.NewFactory(apiSchema, apiSchema) 48 | wh := nethttp.WrapHandler(h, response.EncoderMiddleware, response.ValidatorMiddleware(validatorFactory)) 49 | 50 | assert.True(t, nethttp.MiddlewareIsWrapper(response.ValidatorMiddleware(validatorFactory))) 51 | 52 | w := httptest.NewRecorder() 53 | r, err := http.NewRequest(http.MethodGet, "/", nil) 54 | require.NoError(t, err) 55 | 56 | wh.ServeHTTP(w, r) 57 | assert.Equal(t, http.StatusInternalServerError, w.Code) 58 | assert.Equal(t, `{"status":"INTERNAL","error":"internal: bad response: validation failed",`+ 59 | `"context":{"header:X-Name":["#: length must be >= 3, but got 2"]}}`+"\n", w.Body.String()) 60 | 61 | invalidOut.Name = "Jane" 62 | w = httptest.NewRecorder() 63 | 64 | wh.ServeHTTP(w, r) 65 | assert.Equal(t, http.StatusInternalServerError, w.Code) 66 | assert.Equal(t, `{"status":"INTERNAL","error":"internal: bad response: validation failed",`+ 67 | `"context":{"body":["#/items: minimum 3 items allowed, but found 1 items"]}}`+"\n", w.Body.String()) 68 | } 69 | -------------------------------------------------------------------------------- /resttest/client.go: -------------------------------------------------------------------------------- 1 | package resttest 2 | 3 | import ( 4 | "github.com/bool64/httpmock" 5 | ) 6 | 7 | // Client keeps state of expectations. 8 | // 9 | // Deprecated: please use httpmock.Client. 10 | type Client = httpmock.Client 11 | 12 | // NewClient creates client instance, baseURL may be empty if Client.SetBaseURL is used later. 13 | // 14 | // Deprecated: please use httpmock.NewClient. 15 | func NewClient(baseURL string) *Client { 16 | return httpmock.NewClient(baseURL) 17 | } 18 | -------------------------------------------------------------------------------- /resttest/client_test.go: -------------------------------------------------------------------------------- 1 | package resttest_test 2 | 3 | import ( 4 | "io/ioutil" 5 | "net/http" 6 | "net/http/httptest" 7 | "sync/atomic" 8 | "testing" 9 | 10 | "github.com/bool64/httpmock" 11 | "github.com/bool64/shared" 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | func TestNewClient(t *testing.T) { 16 | cnt := int64(0) 17 | srv := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { 18 | assert.Equal(t, "/foo?q=1", r.URL.String()) 19 | b, err := ioutil.ReadAll(r.Body) 20 | assert.NoError(t, err) 21 | assert.Equal(t, `{"foo":"bar"}`, string(b)) 22 | assert.Equal(t, "application/json", r.Header.Get("Content-Type")) 23 | assert.Equal(t, "abc", r.Header.Get("X-Header")) 24 | assert.Equal(t, "def", r.Header.Get("X-Custom")) 25 | 26 | c, err := r.Cookie("c1") 27 | assert.NoError(t, err) 28 | assert.Equal(t, "1", c.Value) 29 | 30 | c, err = r.Cookie("c2") 31 | assert.NoError(t, err) 32 | assert.Equal(t, "2", c.Value) 33 | 34 | c, err = r.Cookie("foo") 35 | assert.NoError(t, err) 36 | assert.Equal(t, "bar", c.Value) 37 | 38 | ncnt := atomic.AddInt64(&cnt, 1) 39 | 40 | rw.Header().Set("Content-Type", "application/json") 41 | 42 | if ncnt > 1 { 43 | rw.WriteHeader(http.StatusConflict) 44 | _, err := rw.Write([]byte(`{"error":"conflict"}`)) 45 | assert.NoError(t, err) 46 | } else { 47 | rw.WriteHeader(http.StatusAccepted) 48 | _, err := rw.Write([]byte(`{"bar":"foo", "dyn": "abc"}`)) 49 | assert.NoError(t, err) 50 | } 51 | })) 52 | 53 | defer srv.Close() 54 | 55 | vars := &shared.Vars{} 56 | 57 | c := httpmock.NewClient(srv.URL) 58 | c.JSONComparer.Vars = vars 59 | c.ConcurrencyLevel = 50 60 | c.Headers = map[string]string{ 61 | "X-Header": "abc", 62 | } 63 | c.Cookies = map[string]string{ 64 | "foo": "bar", 65 | "c1": "to-be-overridden", 66 | } 67 | 68 | c.Reset(). 69 | WithMethod(http.MethodPost). 70 | WithHeader("X-Custom", "def"). 71 | WithContentType("application/json"). 72 | WithBody([]byte(`{"foo":"bar"}`)). 73 | WithCookie("c1", "1"). 74 | WithCookie("c2", "2"). 75 | WithURI("/foo?q=1"). 76 | Concurrently() 77 | 78 | assert.NoError(t, c.ExpectResponseStatus(http.StatusAccepted)) 79 | assert.NoError(t, c.ExpectResponseBody([]byte(`{"bar":"foo","dyn":"$var1"}`))) 80 | assert.NoError(t, c.ExpectResponseHeader("Content-Type", "application/json")) 81 | assert.NoError(t, c.ExpectOtherResponsesStatus(http.StatusConflict)) 82 | assert.NoError(t, c.ExpectOtherResponsesBody([]byte(`{"error":"conflict"}`))) 83 | assert.NoError(t, c.ExpectOtherResponsesHeader("Content-Type", "application/json")) 84 | assert.NoError(t, c.CheckUnexpectedOtherResponses()) 85 | assert.EqualError(t, c.ExpectNoOtherResponses(), "unexpected response status, expected: 202 (Accepted), received: 409 (Conflict)") 86 | 87 | val, found := vars.Get("$var1") 88 | assert.True(t, found) 89 | assert.Equal(t, "abc", val) 90 | } 91 | 92 | func TestNewClient_failedExpectation(t *testing.T) { 93 | srv := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { 94 | _, err := writer.Write([]byte(`{"bar":"foo"}`)) 95 | assert.NoError(t, err) 96 | })) 97 | defer srv.Close() 98 | c := httpmock.NewClient(srv.URL) 99 | 100 | c.OnBodyMismatch = func(received []byte) { 101 | assert.Equal(t, `{"bar":"foo"}`, string(received)) 102 | println(received) 103 | } 104 | 105 | c.WithURI("/") 106 | assert.EqualError(t, c.ExpectResponseBody([]byte(`{"foo":"bar}"`)), 107 | "unexpected body, expected: \"{\\\"foo\\\":\\\"bar}\\\"\", received: \"{\\\"bar\\\":\\\"foo\\\"}\"") 108 | } 109 | -------------------------------------------------------------------------------- /resttest/doc.go: -------------------------------------------------------------------------------- 1 | // Package resttest provides utilities to test REST API. 2 | // 3 | // Deprecated: please use github.com/bool64/httpmock. 4 | package resttest 5 | -------------------------------------------------------------------------------- /resttest/example_test.go: -------------------------------------------------------------------------------- 1 | package resttest_test 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/bool64/httpmock" 8 | ) 9 | 10 | func ExampleNewClient() { 11 | // Prepare server mock. 12 | sm, url := httpmock.NewServer() 13 | defer sm.Close() 14 | 15 | // This example shows Client and ServerMock working together for sake of portability. 16 | // In real-world scenarios Client would complement real server or ServerMock would complement real HTTP client. 17 | 18 | // Set successful expectation for first request out of concurrent batch. 19 | exp := httpmock.Expectation{ 20 | Method: http.MethodPost, 21 | RequestURI: "/foo?q=1", 22 | RequestHeader: map[string]string{ 23 | "X-Custom": "def", 24 | "X-Header": "abc", 25 | "Content-Type": "application/json", 26 | }, 27 | RequestBody: []byte(`{"foo":"bar"}`), 28 | Status: http.StatusAccepted, 29 | ResponseBody: []byte(`{"bar":"foo"}`), 30 | } 31 | sm.Expect(exp) 32 | 33 | // Set failing expectation for other requests of concurrent batch. 34 | exp.Status = http.StatusConflict 35 | exp.ResponseBody = []byte(`{"error":"conflict"}`) 36 | exp.Unlimited = true 37 | sm.Expect(exp) 38 | 39 | // Prepare client request. 40 | c := httpmock.NewClient(url) 41 | c.ConcurrencyLevel = 50 42 | c.Headers = map[string]string{ 43 | "X-Header": "abc", 44 | } 45 | 46 | c.Reset(). 47 | WithMethod(http.MethodPost). 48 | WithHeader("X-Custom", "def"). 49 | WithContentType("application/json"). 50 | WithBody([]byte(`{"foo":"bar"}`)). 51 | WithURI("/foo?q=1"). 52 | Concurrently() 53 | 54 | // Check expectations errors. 55 | fmt.Println( 56 | c.ExpectResponseStatus(http.StatusAccepted), 57 | c.ExpectResponseBody([]byte(`{"bar":"foo"}`)), 58 | c.ExpectOtherResponsesStatus(http.StatusConflict), 59 | c.ExpectOtherResponsesBody([]byte(`{"error":"conflict"}`)), 60 | ) 61 | 62 | // Output: 63 | // 64 | } 65 | -------------------------------------------------------------------------------- /resttest/server.go: -------------------------------------------------------------------------------- 1 | package resttest 2 | 3 | import ( 4 | "github.com/bool64/httpmock" 5 | ) 6 | 7 | // Expectation describes expected request and defines response. 8 | // 9 | // Deprecated: please use httpmock.Expectation. 10 | type Expectation = httpmock.Expectation 11 | 12 | // ServerMock serves predefined response for predefined request. 13 | type ServerMock = httpmock.Server 14 | 15 | // NewServerMock creates mocked server. 16 | // 17 | // Deprecated: please use httpmock.NewServer. 18 | func NewServerMock() (*ServerMock, string) { 19 | return httpmock.NewServer() 20 | } 21 | -------------------------------------------------------------------------------- /route.go: -------------------------------------------------------------------------------- 1 | package rest 2 | 3 | import ( 4 | "github.com/swaggest/usecase" 5 | ) 6 | 7 | // HandlerWithUseCase exposes usecase. 8 | type HandlerWithUseCase interface { 9 | UseCase() usecase.Interactor 10 | } 11 | 12 | // HandlerWithRoute is a http.Handler with routing information. 13 | type HandlerWithRoute interface { 14 | // RouteMethod returns http method of action. 15 | RouteMethod() string 16 | 17 | // RoutePattern returns http path pattern of action. 18 | RoutePattern() string 19 | } 20 | -------------------------------------------------------------------------------- /trait.go: -------------------------------------------------------------------------------- 1 | package rest 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "net/http" 7 | "reflect" 8 | 9 | "github.com/swaggest/openapi-go" 10 | "github.com/swaggest/openapi-go/openapi3" 11 | "github.com/swaggest/refl" 12 | "github.com/swaggest/usecase" 13 | ) 14 | 15 | // HandlerTrait controls basic behavior of rest handler. 16 | type HandlerTrait struct { 17 | // SuccessStatus is an HTTP status code to set on successful use case interaction. 18 | // 19 | // Default is 200 (OK) or 204 (No Content). 20 | SuccessStatus int 21 | 22 | // SuccessContentType is a Content-Type of successful response, default application/json. 23 | SuccessContentType string 24 | 25 | // MakeErrResp overrides error response builder instead of default Err, 26 | // returned values are HTTP status code and error structure to be marshaled. 27 | MakeErrResp func(ctx context.Context, err error) (int, interface{}) 28 | 29 | // ReqMapping controls request decoding into use case input. 30 | // Optional, if not set field tags are used as mapping. 31 | ReqMapping RequestMapping 32 | 33 | RespHeaderMapping map[string]string 34 | RespCookieMapping map[string]http.Cookie 35 | 36 | // ReqValidator validates decoded request data. 37 | ReqValidator Validator 38 | 39 | // RespValidator validates decoded response data. 40 | RespValidator Validator 41 | 42 | // OperationAnnotations are called after operation setup and before adding operation to documentation. 43 | // 44 | // Deprecated: use OpenAPIAnnotations. 45 | OperationAnnotations []func(op *openapi3.Operation) error 46 | 47 | // OpenAPIAnnotations are called after operation setup and before adding operation to documentation. 48 | OpenAPIAnnotations []func(oc openapi.OperationContext) error 49 | } 50 | 51 | // RestHandler is an accessor. 52 | func (h *HandlerTrait) RestHandler() *HandlerTrait { 53 | return h 54 | } 55 | 56 | // RequestMapping returns custom mapping for request decoder. 57 | func (h *HandlerTrait) RequestMapping() RequestMapping { 58 | return h.ReqMapping 59 | } 60 | 61 | // OutputHasNoContent indicates if output does not seem to have any content body to render in response. 62 | func OutputHasNoContent(output interface{}) bool { 63 | if output == nil { 64 | return true 65 | } 66 | 67 | _, withWriter := output.(usecase.OutputWithWriter) 68 | _, noContent := output.(usecase.OutputWithNoContent) 69 | 70 | rv := reflect.ValueOf(output) 71 | 72 | kind := rv.Kind() 73 | elemKind := reflect.Invalid 74 | 75 | if kind == reflect.Ptr { 76 | elemKind = rv.Elem().Kind() 77 | } 78 | 79 | hasJSONTaggedFields := refl.HasTaggedFields(output, "json") 80 | hasContentTypeTaggedFields := refl.HasTaggedFields(output, "contentType") 81 | isSliceOrMap := refl.IsSliceOrMap(output) 82 | hasEmbeddedSliceOrMap := refl.FindEmbeddedSliceOrMap(output) != nil 83 | isJSONMarshaler := refl.As(output, new(json.Marshaler)) 84 | isPtrToInterface := elemKind == reflect.Interface 85 | isScalar := refl.IsScalar(output) 86 | 87 | if withWriter || 88 | noContent || 89 | hasJSONTaggedFields || 90 | hasContentTypeTaggedFields || 91 | isSliceOrMap || 92 | hasEmbeddedSliceOrMap || 93 | isJSONMarshaler || 94 | isPtrToInterface || 95 | isScalar { 96 | return false 97 | } 98 | 99 | return true 100 | } 101 | -------------------------------------------------------------------------------- /trait_test.go: -------------------------------------------------------------------------------- 1 | package rest_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/swaggest/rest" 8 | ) 9 | 10 | func TestHandlerTrait_RestHandler(t *testing.T) { 11 | h := &rest.HandlerTrait{ 12 | ReqMapping: map[rest.ParamIn]map[string]string{}, 13 | } 14 | assert.Equal(t, h, h.RestHandler()) 15 | assert.Equal(t, h.ReqMapping, h.RequestMapping()) 16 | } 17 | 18 | func TestOutputHasNoContent(t *testing.T) { 19 | assert.True(t, rest.OutputHasNoContent(nil)) 20 | assert.True(t, rest.OutputHasNoContent(struct{}{})) 21 | } 22 | -------------------------------------------------------------------------------- /validator.go: -------------------------------------------------------------------------------- 1 | package rest 2 | 3 | import "encoding/json" 4 | 5 | // Validator validates a map of decoded data. 6 | type Validator interface { 7 | // ValidateData validates decoded request/response data and returns error in case of invalid data. 8 | ValidateData(in ParamIn, namedData map[string]interface{}) error 9 | 10 | // ValidateJSONBody validates JSON encoded body and returns error in case of invalid data. 11 | ValidateJSONBody(jsonBody []byte) error 12 | 13 | // HasConstraints indicates if there are validation rules for parameter location. 14 | HasConstraints(in ParamIn) bool 15 | } 16 | 17 | // ValidatorFunc implements Validator with a func. 18 | type ValidatorFunc func(in ParamIn, namedData map[string]interface{}) error 19 | 20 | // ValidateData implements Validator. 21 | func (v ValidatorFunc) ValidateData(in ParamIn, namedData map[string]interface{}) error { 22 | return v(in, namedData) 23 | } 24 | 25 | // HasConstraints indicates if there are validation rules for parameter location. 26 | func (v ValidatorFunc) HasConstraints(_ ParamIn) bool { 27 | return true 28 | } 29 | 30 | // ValidateJSONBody implements Validator. 31 | func (v ValidatorFunc) ValidateJSONBody(body []byte) error { 32 | return v(ParamInBody, map[string]interface{}{"body": json.RawMessage(body)}) 33 | } 34 | 35 | // RequestJSONSchemaProvider provides request JSON Schemas. 36 | type RequestJSONSchemaProvider interface { 37 | ProvideRequestJSONSchemas( 38 | method string, 39 | input interface{}, 40 | mapping RequestMapping, 41 | validator JSONSchemaValidator, 42 | ) error 43 | } 44 | 45 | // ResponseJSONSchemaProvider provides response JSON Schemas. 46 | type ResponseJSONSchemaProvider interface { 47 | ProvideResponseJSONSchemas( 48 | statusCode int, 49 | contentType string, 50 | output interface{}, 51 | headerMapping map[string]string, 52 | validator JSONSchemaValidator, 53 | ) error 54 | } 55 | 56 | // JSONSchemaValidator defines JSON schema validator. 57 | type JSONSchemaValidator interface { 58 | Validator 59 | 60 | // AddSchema accepts JSON schema for a request parameter or response value. 61 | AddSchema(in ParamIn, name string, schemaData []byte, required bool) error 62 | } 63 | 64 | // RequestValidatorFactory creates request validator for particular structured Go input value. 65 | type RequestValidatorFactory interface { 66 | MakeRequestValidator(method string, input interface{}, mapping RequestMapping) Validator 67 | } 68 | 69 | // ResponseValidatorFactory creates response validator for particular structured Go output value. 70 | type ResponseValidatorFactory interface { 71 | MakeResponseValidator( 72 | statusCode int, 73 | contentType string, 74 | output interface{}, 75 | headerMapping map[string]string, 76 | ) Validator 77 | } 78 | 79 | // ValidationErrors is a list of validation errors. 80 | // 81 | // Key is field position (e.g. "path:id" or "body"), value is a list of issues with the field. 82 | type ValidationErrors map[string][]string 83 | 84 | // Error returns error message. 85 | func (re ValidationErrors) Error() string { 86 | return "validation failed" 87 | } 88 | 89 | // Fields returns request errors by field location and name. 90 | func (re ValidationErrors) Fields() map[string]interface{} { 91 | res := make(map[string]interface{}, len(re)) 92 | 93 | for k, v := range re { 94 | res[k] = v 95 | } 96 | 97 | return res 98 | } 99 | -------------------------------------------------------------------------------- /web/example_test.go: -------------------------------------------------------------------------------- 1 | package web_test 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "net/http" 7 | 8 | "github.com/go-chi/chi/v5/middleware" 9 | "github.com/swaggest/openapi-go/openapi3" 10 | "github.com/swaggest/rest/nethttp" 11 | "github.com/swaggest/rest/web" 12 | "github.com/swaggest/usecase" 13 | ) 14 | 15 | // album represents data about a record album. 16 | type album struct { 17 | ID int `json:"id"` 18 | Title string `json:"title"` 19 | Artist string `json:"artist"` 20 | Price float64 `json:"price"` 21 | Locale string `query:"locale"` 22 | } 23 | 24 | func postAlbums() usecase.Interactor { 25 | u := usecase.NewIOI(new(album), new(album), func(ctx context.Context, input, output interface{}) error { 26 | log.Println("Creating album") 27 | 28 | return nil 29 | }) 30 | u.SetTags("Album") 31 | 32 | return u 33 | } 34 | 35 | func ExampleDefaultService() { 36 | // Service initializes router with required middlewares. 37 | service := web.NewService(openapi3.NewReflector()) 38 | 39 | // It allows OpenAPI configuration. 40 | service.OpenAPISchema().SetTitle("Albums API") 41 | service.OpenAPISchema().SetDescription("This service provides API to manage albums.") 42 | service.OpenAPISchema().SetVersion("v1.0.0") 43 | 44 | // Additional middlewares can be added. 45 | service.Use( 46 | middleware.StripSlashes, 47 | 48 | // cors.AllowAll().Handler, // "github.com/rs/cors", 3rd-party CORS middleware can also be configured here. 49 | ) 50 | 51 | service.Wrap() 52 | 53 | // Use cases can be mounted using short syntax .(...). 54 | service.Post("/albums", postAlbums(), nethttp.SuccessStatus(http.StatusCreated)) 55 | 56 | log.Println("Starting service at http://localhost:8080") 57 | 58 | if err := http.ListenAndServe("localhost:8080", service); err != nil { 59 | log.Fatal(err) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /web/service_test.go: -------------------------------------------------------------------------------- 1 | package web_test 2 | 3 | import ( 4 | "context" 5 | "io/ioutil" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | "github.com/swaggest/assertjson" 13 | "github.com/swaggest/openapi-go/openapi3" 14 | "github.com/swaggest/rest/nethttp" 15 | "github.com/swaggest/rest/web" 16 | "github.com/swaggest/usecase" 17 | ) 18 | 19 | type albumID struct { 20 | ID int `path:"id"` 21 | Locale string `query:"locale"` 22 | } 23 | 24 | func albumByID() usecase.Interactor { 25 | u := usecase.NewIOI(new(albumID), new(album), func(_ context.Context, _, _ interface{}) error { 26 | return nil 27 | }) 28 | u.SetTags("Album") 29 | 30 | return u 31 | } 32 | 33 | func TestDefaultService(t *testing.T) { 34 | var l []string 35 | 36 | service := web.NewService( 37 | openapi3.NewReflector(), 38 | func(_ *web.Service) { l = append(l, "one") }, 39 | func(_ *web.Service) { l = append(l, "two") }, 40 | ) 41 | 42 | service.OpenAPISchema().SetTitle("Albums API") 43 | service.OpenAPISchema().SetDescription("This service provides API to manage albums.") 44 | service.OpenAPISchema().SetVersion("v1.0.0") 45 | 46 | service.Delete("/albums/{id}", albumByID()) 47 | service.Head("/albums/{id}", albumByID()) 48 | service.Get("/albums/{id}", albumByID()) 49 | service.Post("/albums", postAlbums(), nethttp.SuccessStatus(http.StatusCreated)) 50 | service.Patch("/albums", postAlbums(), nethttp.SuccessStatus(http.StatusCreated)) 51 | service.Put("/albums", postAlbums(), nethttp.SuccessStatus(http.StatusCreated)) 52 | service.Trace("/albums", postAlbums(), nethttp.SuccessStatus(http.StatusCreated)) 53 | service.Options("/albums", postAlbums(), nethttp.SuccessStatus(http.StatusCreated)) 54 | service.Docs("/docs", func(_, _, _ string) http.Handler { 55 | // Mount github.com/swaggest/swgui/v4emb.New here. 56 | return http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}) 57 | }) 58 | 59 | service.OnNotFound(usecase.NewIOI( 60 | nil, 61 | new(struct { 62 | Foo string `json:"foo"` 63 | }), 64 | func(_ context.Context, _, _ interface{}) error { 65 | return nil 66 | }), 67 | ) 68 | 69 | service.OnMethodNotAllowed(usecase.NewIOI( 70 | nil, 71 | new(struct { 72 | Foo string `json:"foo"` 73 | }), 74 | func(_ context.Context, _, _ interface{}) error { 75 | return nil 76 | }), 77 | ) 78 | 79 | service.Handle("/a/{id}", nethttp.NewHandler(albumByID())) 80 | 81 | rw := httptest.NewRecorder() 82 | r, err := http.NewRequest(http.MethodGet, "http://localhost/docs/openapi.json", nil) 83 | require.NoError(t, err) 84 | service.ServeHTTP(rw, r) 85 | 86 | assert.Equal(t, http.StatusOK, rw.Code) 87 | assertjson.EqualMarshal(t, rw.Body.Bytes(), service.OpenAPISchema()) 88 | 89 | expected, err := ioutil.ReadFile("_testdata/openapi.json") 90 | require.NoError(t, err) 91 | assertjson.EqualMarshal(t, expected, service.OpenAPISchema()) 92 | 93 | assert.Equal(t, []string{"one", "two"}, l) 94 | } 95 | --------------------------------------------------------------------------------