> $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 | 
--------------------------------------------------------------------------------
/_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 |
--------------------------------------------------------------------------------