├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── question.md └── workflows │ ├── cloc.yml │ ├── golangci-lint.yml │ ├── gorelease.yml │ └── test-unit.yml ├── .gitignore ├── .golangci.yml ├── LICENSE ├── Makefile ├── README.md ├── dev_test.go ├── doc.go ├── error.go ├── error_test.go ├── example_test.go ├── generic_go1.18.go ├── generic_go1.18_test.go ├── go.mod ├── go.sum ├── interactor.go ├── interactor_test.go ├── output.go ├── output_test.go ├── status ├── doc.go ├── error.go ├── error_test.go ├── status.go └── status_test.go ├── wrap.go └── wrap_test.go /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | If feasible/relevant, please provide a code snippet (inline or with Go playground) to reproduce the issue. 15 | 16 | 17 | **Expected behavior** 18 | A clear and concise description of what you expected to happen. 19 | 20 | **Additional context** 21 | Add any other context about the problem here. 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | Please use discussions https://github.com/swaggest/usecase/discussions/categories/ideas to share feature ideas. 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 3 | about: Any question about features or usage 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | Please use discussions https://github.com/swaggest/usecase/discussions/categories/q-a to make your question more discoverable by other folks. 11 | -------------------------------------------------------------------------------- /.github/workflows/cloc.yml: -------------------------------------------------------------------------------- 1 | # This script is provided by github.com/bool64/dev. 2 | name: cloc 3 | on: 4 | pull_request: 5 | 6 | # Cancel the workflow in progress in newer build is about to start. 7 | concurrency: 8 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 9 | cancel-in-progress: true 10 | 11 | jobs: 12 | cloc: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v3 17 | with: 18 | path: pr 19 | - name: Checkout base code 20 | uses: actions/checkout@v3 21 | with: 22 | ref: ${{ github.event.pull_request.base.sha }} 23 | path: base 24 | - name: Count lines of code 25 | id: loc 26 | run: | 27 | curl -sLO https://github.com/vearutop/sccdiff/releases/download/v1.0.3/linux_amd64.tar.gz && tar xf linux_amd64.tar.gz 28 | sccdiff_hash=$(git hash-object ./sccdiff) 29 | [ "$sccdiff_hash" == "ae8a07b687bd3dba60861584efe724351aa7ff63" ] || (echo "::error::unexpected hash for sccdiff, possible tampering: $sccdiff_hash" && exit 1) 30 | OUTPUT=$(cd pr && ../sccdiff -basedir ../base) 31 | echo "${OUTPUT}" 32 | echo "diff<> $GITHUB_OUTPUT && echo "$OUTPUT" >> $GITHUB_OUTPUT && echo "EOF" >> $GITHUB_OUTPUT 33 | 34 | - name: Comment lines of code 35 | continue-on-error: true 36 | uses: marocchino/sticky-pull-request-comment@v2 37 | with: 38 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 39 | header: LOC 40 | message: | 41 | ### Lines Of Code 42 | 43 | ${{ steps.loc.outputs.diff }} 44 | -------------------------------------------------------------------------------- /.github/workflows/golangci-lint.yml: -------------------------------------------------------------------------------- 1 | # This script is provided by github.com/bool64/dev. 2 | name: lint 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | branches: 8 | - master 9 | - main 10 | pull_request: 11 | 12 | # Cancel the workflow in progress in newer build is about to start. 13 | concurrency: 14 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 15 | cancel-in-progress: true 16 | 17 | jobs: 18 | golangci: 19 | name: golangci-lint 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/setup-go@v3 23 | with: 24 | go-version: 1.21.x 25 | - uses: actions/checkout@v2 26 | - name: golangci-lint 27 | uses: golangci/golangci-lint-action@v3.7.0 28 | with: 29 | # Required: the version of golangci-lint is required and must be specified without patch version: we always use the latest patch version. 30 | version: v1.55.2 31 | 32 | # Optional: working directory, useful for monorepos 33 | # working-directory: somedir 34 | 35 | # Optional: golangci-lint command line arguments. 36 | # args: --issues-exit-code=0 37 | 38 | # Optional: show only new issues if it's a pull request. The default value is `false`. 39 | # only-new-issues: true 40 | 41 | # Optional: if set to true then the action will use pre-installed Go. 42 | # skip-go-installation: true 43 | 44 | # Optional: if set to true then the action don't cache or restore ~/go/pkg. 45 | # skip-pkg-cache: true 46 | 47 | # Optional: if set to true then the action don't cache or restore ~/.cache/go-build. 48 | # skip-build-cache: true -------------------------------------------------------------------------------- /.github/workflows/gorelease.yml: -------------------------------------------------------------------------------- 1 | # This script is provided by github.com/bool64/dev. 2 | name: gorelease 3 | on: 4 | pull_request: 5 | 6 | # Cancel the workflow in progress in newer build is about to start. 7 | concurrency: 8 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 9 | cancel-in-progress: true 10 | 11 | env: 12 | GO_VERSION: 1.21.x 13 | jobs: 14 | gorelease: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Install Go stable 18 | if: env.GO_VERSION != 'tip' 19 | uses: actions/setup-go@v4 20 | with: 21 | go-version: ${{ env.GO_VERSION }} 22 | - name: Install Go tip 23 | if: env.GO_VERSION == 'tip' 24 | run: | 25 | curl -sL https://storage.googleapis.com/go-build-snap/go/linux-amd64/$(git ls-remote https://github.com/golang/go.git HEAD | awk '{print $1;}').tar.gz -o gotip.tar.gz 26 | ls -lah gotip.tar.gz 27 | mkdir -p ~/sdk/gotip 28 | tar -C ~/sdk/gotip -xzf gotip.tar.gz 29 | ~/sdk/gotip/bin/go version 30 | echo "PATH=$HOME/go/bin:$HOME/sdk/gotip/bin/:$PATH" >> $GITHUB_ENV 31 | - name: Checkout code 32 | uses: actions/checkout@v3 33 | - name: Gorelease cache 34 | uses: actions/cache@v3 35 | with: 36 | path: | 37 | ~/go/bin/gorelease 38 | key: ${{ runner.os }}-gorelease-generic 39 | - name: Gorelease 40 | id: gorelease 41 | run: | 42 | test -e ~/go/bin/gorelease || go install golang.org/x/exp/cmd/gorelease@latest 43 | OUTPUT=$(gorelease 2>&1 || exit 0) 44 | echo "${OUTPUT}" 45 | echo "report<> $GITHUB_OUTPUT && echo "$OUTPUT" >> $GITHUB_OUTPUT && echo "EOF" >> $GITHUB_OUTPUT 46 | - name: Comment report 47 | continue-on-error: true 48 | uses: marocchino/sticky-pull-request-comment@v2 49 | with: 50 | header: gorelease 51 | message: | 52 | ### Go API Changes 53 | 54 |
55 |             ${{ steps.gorelease.outputs.report }}
56 |             
-------------------------------------------------------------------------------- /.github/workflows/test-unit.yml: -------------------------------------------------------------------------------- 1 | # This script is provided by github.com/bool64/dev. 2 | name: test-unit 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - main 8 | pull_request: 9 | 10 | # Cancel the workflow in progress in newer build is about to start. 11 | concurrency: 12 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 13 | cancel-in-progress: true 14 | 15 | env: 16 | GO111MODULE: "on" 17 | RUN_BASE_COVERAGE: "on" # Runs test for PR base in case base test coverage is missing. 18 | COV_GO_VERSION: 1.21.x # Version of Go to collect coverage 19 | TARGET_DELTA_COV: 90 # Target coverage of changed lines, in percents 20 | jobs: 21 | test: 22 | strategy: 23 | matrix: 24 | go-version: [ 1.13.x, 1.20.x, 1.21.x ] 25 | runs-on: ubuntu-latest 26 | steps: 27 | - name: Install Go stable 28 | if: matrix.go-version != 'tip' 29 | uses: actions/setup-go@v4 30 | with: 31 | go-version: ${{ matrix.go-version }} 32 | 33 | - name: Install Go tip 34 | if: matrix.go-version == 'tip' 35 | run: | 36 | curl -sL https://storage.googleapis.com/go-build-snap/go/linux-amd64/$(git ls-remote https://github.com/golang/go.git HEAD | awk '{print $1;}').tar.gz -o gotip.tar.gz 37 | ls -lah gotip.tar.gz 38 | mkdir -p ~/sdk/gotip 39 | tar -C ~/sdk/gotip -xzf gotip.tar.gz 40 | ~/sdk/gotip/bin/go version 41 | echo "PATH=$HOME/go/bin:$HOME/sdk/gotip/bin/:$PATH" >> $GITHUB_ENV 42 | 43 | - name: Checkout code 44 | uses: actions/checkout@v3 45 | 46 | - name: Go cache 47 | uses: actions/cache@v3 48 | with: 49 | # In order: 50 | # * Module download cache 51 | # * Build cache (Linux) 52 | path: | 53 | ~/go/pkg/mod 54 | ~/.cache/go-build 55 | key: ${{ runner.os }}-go-cache-${{ hashFiles('**/go.sum') }} 56 | restore-keys: | 57 | ${{ runner.os }}-go-cache 58 | 59 | - name: Restore base test coverage 60 | id: base-coverage 61 | if: matrix.go-version == env.COV_GO_VERSION && github.event.pull_request.base.sha != '' 62 | uses: actions/cache@v2 63 | with: 64 | path: | 65 | unit-base.txt 66 | # Use base sha for PR or new commit hash for master/main push in test result key. 67 | key: ${{ runner.os }}-unit-test-coverage-${{ (github.event.pull_request.base.sha != github.event.after) && github.event.pull_request.base.sha || github.event.after }} 68 | 69 | - name: Run test for base code 70 | if: matrix.go-version == env.COV_GO_VERSION && env.RUN_BASE_COVERAGE == 'on' && steps.base-coverage.outputs.cache-hit != 'true' && github.event.pull_request.base.sha != '' 71 | run: | 72 | git fetch origin master ${{ github.event.pull_request.base.sha }} 73 | HEAD=$(git rev-parse HEAD) 74 | git reset --hard ${{ github.event.pull_request.base.sha }} 75 | (make test-unit && go tool cover -func=./unit.coverprofile > unit-base.txt) || echo "No test-unit in base" 76 | git reset --hard $HEAD 77 | 78 | - name: Test 79 | id: test 80 | run: | 81 | make test-unit 82 | go tool cover -func=./unit.coverprofile > unit.txt 83 | TOTAL=$(grep 'total:' unit.txt) 84 | echo "${TOTAL}" 85 | echo "total=$TOTAL" >> $GITHUB_OUTPUT 86 | 87 | - name: Annotate missing test coverage 88 | id: annotate 89 | if: matrix.go-version == env.COV_GO_VERSION && github.event.pull_request.base.sha != '' 90 | run: | 91 | curl -sLO https://github.com/vearutop/gocovdiff/releases/download/v1.4.2/linux_amd64.tar.gz && tar xf linux_amd64.tar.gz && rm linux_amd64.tar.gz 92 | gocovdiff_hash=$(git hash-object ./gocovdiff) 93 | [ "$gocovdiff_hash" == "c37862c73a677e5a9c069470287823ab5bbf0244" ] || (echo "::error::unexpected hash for gocovdiff, possible tampering: $gocovdiff_hash" && exit 1) 94 | git fetch origin master ${{ github.event.pull_request.base.sha }} 95 | REP=$(./gocovdiff -mod github.com/$GITHUB_REPOSITORY -cov unit.coverprofile -gha-annotations gha-unit.txt -delta-cov-file delta-cov-unit.txt -target-delta-cov ${TARGET_DELTA_COV}) 96 | echo "${REP}" 97 | cat gha-unit.txt 98 | DIFF=$(test -e unit-base.txt && ./gocovdiff -mod github.com/$GITHUB_REPOSITORY -func-cov unit.txt -func-base-cov unit-base.txt || echo "Missing base coverage file") 99 | TOTAL=$(cat delta-cov-unit.txt) 100 | echo "rep<> $GITHUB_OUTPUT && echo "$REP" >> $GITHUB_OUTPUT && echo "EOF" >> $GITHUB_OUTPUT 101 | echo "diff<> $GITHUB_OUTPUT && echo "$DIFF" >> $GITHUB_OUTPUT && echo "EOF" >> $GITHUB_OUTPUT 102 | echo "total<> $GITHUB_OUTPUT && echo "$TOTAL" >> $GITHUB_OUTPUT && echo "EOF" >> $GITHUB_OUTPUT 103 | 104 | - name: Comment test coverage 105 | continue-on-error: true 106 | if: matrix.go-version == env.COV_GO_VERSION && github.event.pull_request.base.sha != '' 107 | uses: marocchino/sticky-pull-request-comment@v2 108 | with: 109 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 110 | header: unit-test 111 | message: | 112 | ### Unit Test Coverage 113 | ${{ steps.test.outputs.total }} 114 | ${{ steps.annotate.outputs.total }} 115 |
Coverage of changed lines 116 | 117 | ${{ steps.annotate.outputs.rep }} 118 | 119 |
120 | 121 |
Coverage diff with base branch 122 | 123 | ${{ steps.annotate.outputs.diff }} 124 | 125 |
126 | 127 | - name: Store base coverage 128 | if: ${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main' }} 129 | run: cp unit.txt unit-base.txt 130 | 131 | - name: Upload code coverage 132 | if: matrix.go-version == env.COV_GO_VERSION 133 | uses: codecov/codecov-action@v1 134 | with: 135 | file: ./unit.coverprofile 136 | flags: unittests 137 | -------------------------------------------------------------------------------- /.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 | unused: 16 | check-exported: false 17 | unparam: 18 | check-exported: true 19 | 20 | linters: 21 | enable-all: true 22 | disable: 23 | - testifylint 24 | - lll 25 | - maligned 26 | - gochecknoglobals 27 | - gomnd 28 | - wrapcheck 29 | - paralleltest 30 | - forbidigo 31 | - exhaustivestruct 32 | - interfacer # deprecated 33 | - forcetypeassert 34 | - scopelint # deprecated 35 | - ifshort # too many false positives 36 | - golint # deprecated 37 | - varnamelen 38 | - tagliatelle 39 | - errname 40 | - ireturn 41 | - exhaustruct 42 | - nonamedreturns 43 | - nosnakecase 44 | - structcheck 45 | - varcheck 46 | - deadcode 47 | - testableexamples 48 | - dupword 49 | - depguard 50 | - tagalign 51 | 52 | issues: 53 | exclude-use-default: false 54 | exclude-rules: 55 | - linters: 56 | - errorlint 57 | - gomnd 58 | - goconst 59 | - goerr113 60 | - noctx 61 | - funlen 62 | - dupl 63 | - structcheck 64 | - unused 65 | - unparam 66 | - nosnakecase 67 | path: "_test.go" 68 | - linters: 69 | - errcheck # Error checking omitted for brevity. 70 | - gosec 71 | path: "example_" 72 | 73 | -------------------------------------------------------------------------------- /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.55.2" # 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 | -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/reset-ci.mk 34 | 35 | # Add your custom targets here. 36 | 37 | ## Run tests 38 | test: test-unit 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Use Case Interactor 2 | 3 | [![Build Status](https://github.com/swaggest/usecase/workflows/test-unit/badge.svg)](https://github.com/swaggest/usecase/actions?query=branch%3Amaster+workflow%3Atest-unit) 4 | [![Coverage Status](https://codecov.io/gh/swaggest/usecase/branch/master/graph/badge.svg)](https://codecov.io/gh/swaggest/usecase) 5 | [![GoDevDoc](https://img.shields.io/badge/dev-doc-00ADD8?logo=go)](https://pkg.go.dev/github.com/swaggest/usecase) 6 | ![Code lines](https://sloc.xyz/github/swaggest/usecase/?category=code) 7 | ![Comments](https://sloc.xyz/github/swaggest/usecase/?category=comments) 8 | 9 | This module defines generalized contract of *Use Case Interactor* to enable 10 | [The Clean Architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) 11 | in Go application. 12 | 13 | ![Clean Architecture](https://blog.cleancoder.com/uncle-bob/images/2012-08-13-the-clean-architecture/CleanArchitecture.jpg) 14 | 15 | ## Why? 16 | 17 | Isolating transport layer from business logic reduces coupling and allows better control on both transport and business 18 | sides. For example the application needs to consume AMQP events and act on them, with isolated use case interactor it is 19 | easy to trigger same action with HTTP message (as a part of developer tools). 20 | 21 | Use case interactors declare their ports and may serve as a source of information for documentation automation. 22 | 23 | This abstraction is intended for use with automated transport layer, for example see [`REST`](https://github.com/swaggest/rest). 24 | 25 | ## Usage 26 | 27 | ### Input/Output Definitions 28 | 29 | ```go 30 | // Configure use case interactor in application layer. 31 | type myInput struct { 32 | Param1 int `path:"param1" description:"Parameter in resource path." multipleOf:"2"` 33 | Param2 string `json:"param2" description:"Parameter in resource body."` 34 | } 35 | 36 | type myOutput struct { 37 | Value1 int `json:"value1"` 38 | Value2 string `json:"value2"` 39 | } 40 | 41 | ``` 42 | ### Classic API 43 | 44 | ```go 45 | u := usecase.NewIOI(new(myInput), new(myOutput), func(ctx context.Context, input, output interface{}) error { 46 | var ( 47 | in = input.(*myInput) 48 | out = output.(*myOutput) 49 | ) 50 | 51 | if in.Param1%2 != 0 { 52 | return status.InvalidArgument 53 | } 54 | 55 | // Do something to set output based on input. 56 | out.Value1 = in.Param1 + in.Param1 57 | out.Value2 = in.Param2 + in.Param2 58 | 59 | return nil 60 | }) 61 | 62 | ``` 63 | 64 | ### Generic API with type parameters 65 | 66 | With `go1.18` and later (or [`gotip`](https://pkg.go.dev/golang.org/dl/gotip)) you can use simplified generic API instead 67 | of classic API based on `interface{}`. 68 | 69 | ```go 70 | u := usecase.NewInteractor(func(ctx context.Context, input myInput, output *myOutput) error { 71 | if in.Param1%2 != 0 { 72 | return status.InvalidArgument 73 | } 74 | 75 | // Do something to set output based on input. 76 | out.Value1 = in.Param1 + in.Param1 77 | out.Value2 = in.Param2 + in.Param2 78 | 79 | return nil 80 | }) 81 | ``` 82 | 83 | ### Further Configuration And Usage 84 | 85 | ```go 86 | // Additional properties can be configured for purposes of automated documentation. 87 | u.SetTitle("Doubler") 88 | u.SetDescription("Doubler doubles parameter values.") 89 | u.SetTags("transformation") 90 | u.SetExpectedErrors(status.InvalidArgument) 91 | u.SetIsDeprecated(true) 92 | ``` 93 | 94 | Then use configured use case interactor with transport/documentation/etc adapter. 95 | 96 | For example with [REST](https://github.com/swaggest/rest/blob/v0.1.18/_examples/basic/main.go#L95-L96) router: 97 | ```go 98 | // Add use case handler to router. 99 | r.Method(http.MethodPost, "/double/{param1}", nethttp.NewHandler(u)) 100 | ``` -------------------------------------------------------------------------------- /dev_test.go: -------------------------------------------------------------------------------- 1 | package usecase_test 2 | 3 | import _ "github.com/bool64/dev" // Include CI/Dev scripts to project. 4 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | // Package usecase defines use case interactor. 2 | package usecase 3 | -------------------------------------------------------------------------------- /error.go: -------------------------------------------------------------------------------- 1 | package usecase 2 | 3 | import "github.com/swaggest/usecase/status" 4 | 5 | // Error is an error with contextual information. 6 | type Error struct { 7 | AppCode int 8 | StatusCode status.Code 9 | Value error 10 | Context map[string]interface{} 11 | } 12 | 13 | // Error returns error message. 14 | func (e Error) Error() string { 15 | return e.Unwrap().Error() 16 | } 17 | 18 | // Fields exposes structured context of error. 19 | func (e Error) Fields() map[string]interface{} { 20 | return e.Context 21 | } 22 | 23 | // AppErrCode returns application level error code. 24 | func (e Error) AppErrCode() int { 25 | return e.AppCode 26 | } 27 | 28 | // Status returns status code of error. 29 | func (e Error) Status() status.Code { 30 | return e.StatusCode 31 | } 32 | 33 | // Unwrap returns parent error. 34 | func (e Error) Unwrap() error { 35 | if e.StatusCode != 0 { 36 | return status.Wrap(e.Value, e.StatusCode) 37 | } 38 | 39 | return e.Value 40 | } 41 | 42 | type sentinelError string 43 | 44 | func (s sentinelError) Error() string { 45 | return string(s) 46 | } 47 | -------------------------------------------------------------------------------- /error_test.go: -------------------------------------------------------------------------------- 1 | package usecase_test 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/swaggest/usecase" 9 | "github.com/swaggest/usecase/status" 10 | ) 11 | 12 | func TestError(t *testing.T) { 13 | e := usecase.Error{ 14 | AppCode: 123, 15 | StatusCode: status.FailedPrecondition, 16 | Context: map[string]interface{}{ 17 | "foo": "bar", 18 | }, 19 | } 20 | assert.Equal(t, 123, e.AppErrCode()) 21 | assert.Equal(t, e.Context, e.Fields()) 22 | assert.Equal(t, status.FailedPrecondition, e.Status()) 23 | assert.EqualError(t, e, "failed precondition") 24 | assert.EqualError(t, e.Unwrap(), "failed precondition") 25 | 26 | e.Value = errors.New("failed") 27 | assert.EqualError(t, e, "failed precondition: failed") 28 | assert.EqualError(t, e.Unwrap(), "failed precondition: failed") 29 | 30 | e.StatusCode = 0 31 | assert.EqualError(t, e, "failed") 32 | assert.EqualError(t, e.Unwrap(), "failed") 33 | } 34 | -------------------------------------------------------------------------------- /example_test.go: -------------------------------------------------------------------------------- 1 | package usecase_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | 8 | "github.com/swaggest/usecase" 9 | "github.com/swaggest/usecase/status" 10 | ) 11 | 12 | func ExampleNewIOI() { 13 | // Configure use case interactor in application layer. 14 | type myInput struct { 15 | Param1 int `path:"param1" description:"Parameter in resource path." multipleOf:"2"` 16 | Param2 string `json:"param2" description:"Parameter in resource body."` 17 | } 18 | 19 | type myOutput struct { 20 | Value1 int `json:"value1"` 21 | Value2 string `json:"value2"` 22 | } 23 | 24 | u := usecase.NewIOI(new(myInput), new(myOutput), func(ctx context.Context, input, output interface{}) error { 25 | var ( 26 | in = input.(*myInput) 27 | out = output.(*myOutput) 28 | ) 29 | 30 | if in.Param1%2 != 0 { 31 | return status.InvalidArgument 32 | } 33 | 34 | // Do something to set output based on input. 35 | out.Value1 = in.Param1 + in.Param1 36 | out.Value2 = in.Param2 + in.Param2 37 | 38 | return nil 39 | }) 40 | 41 | // Additional properties can be configured for purposes of automated documentation. 42 | u.SetTitle("Doubler") 43 | u.SetDescription("Doubler doubles parameter values.") 44 | u.SetTags("transformation") 45 | u.SetExpectedErrors(status.InvalidArgument) 46 | u.SetIsDeprecated(true) 47 | 48 | // The code below illustrates transport side. 49 | 50 | // At transport layer, input and out ports are to be examined and populated using reflection. 51 | // For example request body could be json unmarshaled, or request parameters can be mapped. 52 | input := new(myInput) 53 | // input := reflect.New(reflect.TypeOf(u.InputPort())) 54 | input.Param1 = 1234 55 | input.Param2 = "abc" 56 | 57 | output := new(myOutput) 58 | // output := reflect.New(reflect.TypeOf(u.OutputPort())) 59 | 60 | // When input is prepared and output is initialized, transport should invoke interaction. 61 | err := u.Interact(context.TODO(), input, output) 62 | if err != nil { 63 | log.Fatal(err) 64 | } 65 | 66 | // And make some use of prepared output. 67 | fmt.Printf("%+v\n", output) 68 | 69 | // Output: 70 | // &{Value1:2468 Value2:abcabc} 71 | } 72 | -------------------------------------------------------------------------------- /generic_go1.18.go: -------------------------------------------------------------------------------- 1 | //go:build go1.18 2 | // +build go1.18 3 | 4 | package usecase 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | ) 10 | 11 | // ErrInvalidType is returned on port type assertion error. 12 | const ErrInvalidType = sentinelError("invalid type") 13 | 14 | // IOInteractorOf is an IOInteractor with parametrized input/output types. 15 | type IOInteractorOf[i, o any] struct { 16 | IOInteractor 17 | 18 | InteractFunc func(ctx context.Context, input i, output *o) error 19 | } 20 | 21 | // Invoke calls interact function in a type-safe way. 22 | func (ioi IOInteractorOf[i, o]) Invoke(ctx context.Context, input i, output *o) error { 23 | return ioi.InteractFunc(ctx, input, output) 24 | } 25 | 26 | // NewInteractor creates generic use case interactor with input and output ports. 27 | // 28 | // It pre-fills name and title with caller function. 29 | // Input is passed by value, while output is passed by pointer to be mutable. 30 | func NewInteractor[i, o any](interact func(ctx context.Context, input i, output *o) error, options ...func(i *IOInteractor)) IOInteractorOf[i, o] { 31 | u := IOInteractorOf[i, o]{} 32 | u.Input = *new(i) 33 | u.Output = new(o) 34 | u.InteractFunc = interact 35 | u.Interactor = Interact(func(ctx context.Context, input, output any) error { 36 | inp, ok := input.(i) 37 | if !ok { 38 | return fmt.Errorf("%w of input: %T, expected: %T", ErrInvalidType, input, u.Input) 39 | } 40 | 41 | out, ok := output.(*o) 42 | if !ok { 43 | return fmt.Errorf("%w f output: %T, expected: %T", ErrInvalidType, output, u.Output) 44 | } 45 | 46 | return interact(ctx, inp, out) 47 | }) 48 | 49 | u.name, u.title = callerFunc() 50 | u.name = filterName(u.name) 51 | 52 | for _, o := range options { 53 | o(&u.IOInteractor) 54 | } 55 | 56 | return u 57 | } 58 | -------------------------------------------------------------------------------- /generic_go1.18_test.go: -------------------------------------------------------------------------------- 1 | //go:build go1.18 2 | // +build go1.18 3 | 4 | package usecase_test 5 | 6 | import ( 7 | "context" 8 | "strconv" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/assert" 12 | "github.com/swaggest/usecase" 13 | ) 14 | 15 | func TestNewIOI_classic(t *testing.T) { 16 | u := usecase.NewIOI(0, new(string), func(ctx context.Context, input, output interface{}) error { 17 | in, ok := input.(int) 18 | assert.True(t, ok) 19 | 20 | out, ok := output.(*string) 21 | assert.True(t, ok) 22 | 23 | *out = strconv.Itoa(in) 24 | 25 | return nil 26 | }) 27 | 28 | ctx := context.Background() 29 | 30 | var out string 31 | 32 | assert.NoError(t, u.Interact(ctx, 123, &out)) 33 | assert.Equal(t, "123", out) 34 | } 35 | 36 | func TestNewInteractor(t *testing.T) { 37 | u := usecase.NewInteractor(func(ctx context.Context, input int, output *string) error { 38 | *output = strconv.Itoa(input) 39 | 40 | return nil 41 | }, func(i *usecase.IOInteractor) { 42 | i.SetTags("foo") 43 | }) 44 | 45 | u.SetDescription("Foo.") 46 | 47 | ctx := context.Background() 48 | 49 | var out string 50 | 51 | assert.NoError(t, u.Interact(ctx, 123, &out)) 52 | assert.Equal(t, "123", out) 53 | 54 | out = "" 55 | assert.NoError(t, u.Invoke(ctx, 123, &out)) 56 | assert.Equal(t, "123", out) 57 | 58 | assert.Equal(t, "invalid type", usecase.ErrInvalidType.Error()) 59 | 60 | assert.Equal(t, []string{"foo"}, u.Tags()) 61 | } 62 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/swaggest/usecase 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/bool64/dev v0.2.32 7 | github.com/stretchr/testify v1.8.0 8 | ) 9 | 10 | require ( 11 | github.com/davecgh/go-spew v1.1.1 // indirect 12 | github.com/pmezard/go-difflib v1.0.0 // indirect 13 | gopkg.in/yaml.v3 v3.0.1 // indirect 14 | ) 15 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/bool64/dev v0.2.32 h1:DRZtloaoH1Igky3zphaUHV9+SLIV2H3lsf78JsJHFg0= 2 | github.com/bool64/dev v0.2.32/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= 3 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 5 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 7 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 8 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 9 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 10 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 11 | github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= 12 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 13 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 14 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 15 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 16 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 17 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 18 | -------------------------------------------------------------------------------- /interactor.go: -------------------------------------------------------------------------------- 1 | package usecase 2 | 3 | import ( 4 | "context" 5 | "path" 6 | "runtime" 7 | "strings" 8 | "unicode" 9 | "unicode/utf8" 10 | ) 11 | 12 | // Interactor orchestrates the flow of data to and from the entities, 13 | // and direct those entities to use their enterprise 14 | // wide business rules to achieve the goals of the use case. 15 | type Interactor interface { 16 | // Interact sets output port value with regards to input port value or fails. 17 | Interact(ctx context.Context, input, output interface{}) error 18 | } 19 | 20 | // Interact makes use case interactor from function. 21 | type Interact func(ctx context.Context, input, output interface{}) error 22 | 23 | // Interact implements Interactor. 24 | func (i Interact) Interact(ctx context.Context, input, output interface{}) error { 25 | return i(ctx, input, output) 26 | } 27 | 28 | // HasInputPort declares input port. 29 | type HasInputPort interface { 30 | // InputPort returns sample of input value, e.g. new(MyInput). 31 | InputPort() interface{} 32 | } 33 | 34 | // WithInput is an embeddable implementation of HasInputPort. 35 | type WithInput struct { 36 | Input interface{} 37 | } 38 | 39 | // InputPort implements HasInputPort. 40 | func (wi WithInput) InputPort() interface{} { 41 | return wi.Input 42 | } 43 | 44 | // HasOutputPort declares output port. 45 | type HasOutputPort interface { 46 | // OutputPort returns sample of output value, e.g. new(MyOutput). 47 | OutputPort() interface{} 48 | } 49 | 50 | // WithOutput is an embeddable implementation of HasOutputPort. 51 | type WithOutput struct { 52 | Output interface{} 53 | } 54 | 55 | // OutputPort implements HasOutputPort. 56 | func (wi WithOutput) OutputPort() interface{} { 57 | return wi.Output 58 | } 59 | 60 | // HasTitle declares title. 61 | type HasTitle interface { 62 | Title() string 63 | } 64 | 65 | // HasName declares title. 66 | type HasName interface { 67 | Name() string 68 | } 69 | 70 | // HasDescription declares description. 71 | type HasDescription interface { 72 | Description() string 73 | } 74 | 75 | // HasTags declares tags of use cases group. 76 | type HasTags interface { 77 | Tags() []string 78 | } 79 | 80 | // HasExpectedErrors declares errors that are expected to cause use case failure. 81 | type HasExpectedErrors interface { 82 | ExpectedErrors() []error 83 | } 84 | 85 | // HasIsDeprecated declares status of deprecation. 86 | type HasIsDeprecated interface { 87 | IsDeprecated() bool 88 | } 89 | 90 | // Info exposes information about use case. 91 | type Info struct { 92 | name string 93 | title string 94 | description string 95 | tags []string 96 | expectedErrors []error 97 | isDeprecated bool 98 | } 99 | 100 | var ( 101 | _ HasTags = Info{} 102 | _ HasTitle = Info{} 103 | _ HasName = Info{} 104 | _ HasDescription = Info{} 105 | _ HasIsDeprecated = Info{} 106 | _ HasExpectedErrors = Info{} 107 | ) 108 | 109 | // IsDeprecated implements HasIsDeprecated. 110 | func (i Info) IsDeprecated() bool { 111 | return i.isDeprecated 112 | } 113 | 114 | // SetIsDeprecated sets status of deprecation. 115 | func (i *Info) SetIsDeprecated(isDeprecated bool) { 116 | i.isDeprecated = isDeprecated 117 | } 118 | 119 | // ExpectedErrors implements HasExpectedErrors. 120 | func (i Info) ExpectedErrors() []error { 121 | return i.expectedErrors 122 | } 123 | 124 | // SetExpectedErrors sets errors that are expected to cause use case failure. 125 | func (i *Info) SetExpectedErrors(expectedErrors ...error) { 126 | i.expectedErrors = expectedErrors 127 | } 128 | 129 | // Tags implements HasTag. 130 | func (i Info) Tags() []string { 131 | return i.tags 132 | } 133 | 134 | // SetTags sets tags of use cases group. 135 | func (i *Info) SetTags(tags ...string) { 136 | i.tags = tags 137 | } 138 | 139 | // Description implements HasDescription. 140 | func (i Info) Description() string { 141 | return i.description 142 | } 143 | 144 | // SetDescription sets use case description. 145 | func (i *Info) SetDescription(description string) { 146 | i.description = description 147 | } 148 | 149 | // Title implements HasTitle. 150 | func (i Info) Title() string { 151 | return i.title 152 | } 153 | 154 | // SetTitle sets use case title. 155 | func (i *Info) SetTitle(title string) { 156 | i.title = title 157 | } 158 | 159 | // Name implements HasName. 160 | func (i Info) Name() string { 161 | return i.name 162 | } 163 | 164 | // SetName sets use case title. 165 | func (i *Info) SetName(name string) { 166 | i.name = name 167 | } 168 | 169 | // IOInteractor is an interactor with input and output. 170 | type IOInteractor struct { 171 | Interactor 172 | Info 173 | WithInput 174 | WithOutput 175 | } 176 | 177 | // NewIOI creates use case interactor with input, output and interact action function. 178 | // 179 | // It pre-fills name and title with caller function. 180 | func NewIOI(input, output interface{}, interact Interact, options ...func(i *IOInteractor)) IOInteractor { 181 | u := IOInteractor{} 182 | u.Input = input 183 | u.Output = output 184 | u.Interactor = interact 185 | 186 | u.name, u.title = callerFunc() 187 | u.name = filterName(u.name) 188 | 189 | for _, o := range options { 190 | o(&u) 191 | } 192 | 193 | return u 194 | } 195 | 196 | var titleReplacer = strings.NewReplacer( 197 | "(", "", 198 | ".", "", 199 | "*", "", 200 | ")", "", 201 | ) 202 | 203 | func filterName(name string) string { 204 | name = strings.TrimPrefix(name, "internal/") 205 | name = strings.TrimPrefix(name, "usecase.") 206 | name = strings.TrimPrefix(name, "usecase/") 207 | name = strings.TrimPrefix(name, "./main.") 208 | 209 | return name 210 | } 211 | 212 | // callerFunc returns trimmed path and name of parent function. 213 | func callerFunc() (string, string) { 214 | skipFrames := 2 215 | 216 | pc, _, _, ok := runtime.Caller(skipFrames) 217 | if !ok { 218 | return "", "" 219 | } 220 | 221 | f := runtime.FuncForPC(pc) 222 | 223 | pathName := path.Base(path.Dir(f.Name())) + "/" + path.Base(f.Name()) 224 | title := path.Base(f.Name()) 225 | 226 | parts := strings.SplitN(title, ".", 2) 227 | if len(parts) != 1 { 228 | title = parts[len(parts)-1] 229 | if len(title) == 0 { 230 | return pathName, "" 231 | } 232 | 233 | // Uppercase first character of title. 234 | r := []rune(title) 235 | r[0] = unicode.ToUpper(r[0]) 236 | title = string(r) 237 | 238 | title = titleReplacer.Replace(title) 239 | title = splitCamelcase(title) 240 | } 241 | 242 | return pathName, title 243 | } 244 | 245 | // borrowed from https://pkg.go.dev/github.com/fatih/camelcase#Split to avoid external dependency. 246 | func splitCamelcase(src string) string { //nolint:cyclop 247 | // don't split invalid utf8 248 | if !utf8.ValidString(src) { 249 | return src 250 | } 251 | 252 | var ( 253 | entries []string 254 | runes [][]rune 255 | ) 256 | 257 | var class, lastClass int 258 | 259 | // split into fields based on class of unicode character 260 | for _, r := range src { 261 | switch { 262 | case unicode.IsLower(r): 263 | class = 1 264 | case unicode.IsUpper(r): 265 | class = 2 266 | case unicode.IsDigit(r): 267 | class = 3 268 | default: 269 | class = 4 270 | } 271 | 272 | if class == lastClass && runes != nil { 273 | runes[len(runes)-1] = append(runes[len(runes)-1], r) 274 | } else { 275 | runes = append(runes, []rune{r}) 276 | } 277 | 278 | lastClass = class 279 | } 280 | // handle upper case -> lower case sequences, e.g. 281 | // "PDFL", "oader" -> "PDF", "Loader" 282 | for i := 0; i < len(runes)-1; i++ { 283 | if unicode.IsUpper(runes[i][0]) && unicode.IsLower(runes[i+1][0]) { 284 | runes[i+1] = append([]rune{runes[i][len(runes[i])-1]}, runes[i+1]...) 285 | runes[i] = runes[i][:len(runes[i])-1] 286 | } 287 | } 288 | // construct []string from results 289 | for _, s := range runes { 290 | if len(s) > 0 { 291 | entries = append(entries, string(s)) 292 | } 293 | } 294 | 295 | return strings.Join(entries, " ") 296 | } 297 | -------------------------------------------------------------------------------- /interactor_test.go: -------------------------------------------------------------------------------- 1 | package usecase_test 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/swaggest/usecase" 10 | "github.com/swaggest/usecase/status" 11 | ) 12 | 13 | func TestInteract_Interact(t *testing.T) { 14 | err := errors.New("failed") 15 | inp := new(string) 16 | out := new(int) 17 | 18 | i := usecase.Interact(func(ctx context.Context, input, output interface{}) error { 19 | assert.Equal(t, inp, input) 20 | assert.Equal(t, out, output) 21 | 22 | return err 23 | }) 24 | assert.Equal(t, err, i.Interact(context.Background(), inp, out)) 25 | } 26 | 27 | func TestInfo(t *testing.T) { 28 | i := usecase.Info{} 29 | i.SetName("name") 30 | i.SetDescription("Description") 31 | i.SetTitle("Title") 32 | i.SetTags("tag1", "tag2") 33 | i.SetIsDeprecated(true) 34 | i.SetExpectedErrors(usecase.Error{StatusCode: status.InvalidArgument}) 35 | 36 | assert.Equal(t, "name", i.Name()) 37 | assert.Equal(t, "Description", i.Description()) 38 | assert.Equal(t, "Title", i.Title()) 39 | assert.Equal(t, []string{"tag1", "tag2"}, i.Tags()) 40 | assert.Equal(t, true, i.IsDeprecated()) 41 | assert.Equal(t, []error{usecase.Error{StatusCode: status.InvalidArgument}}, i.ExpectedErrors()) 42 | } 43 | 44 | type Foo struct{} 45 | 46 | func (f *Foo) Bar() usecase.IOInteractor { 47 | return usecase.NewIOI(nil, nil, func(ctx context.Context, input, output interface{}) error { 48 | return nil 49 | }) 50 | } 51 | 52 | func (f Foo) Baz() usecase.IOInteractor { 53 | return usecase.NewIOI(nil, nil, func(ctx context.Context, input, output interface{}) error { 54 | return nil 55 | }) 56 | } 57 | 58 | func TestNewIOI(t *testing.T) { 59 | u := usecase.NewIOI(new(string), new(int), func(ctx context.Context, input, output interface{}) error { 60 | return nil 61 | }, func(i *usecase.IOInteractor) { 62 | i.SetTags("foo") 63 | }) 64 | 65 | assert.Equal(t, "swaggest/usecase_test.TestNewIOI", u.Name()) 66 | assert.Equal(t, "Test New IOI", u.Title()) 67 | assert.Equal(t, []string{"foo"}, u.Tags()) 68 | 69 | u = (&Foo{}).Bar() 70 | assert.Equal(t, "swaggest/usecase_test.(*Foo).Bar", u.Name()) 71 | assert.Equal(t, "Foo Bar", u.Title()) 72 | 73 | u = Foo{}.Baz() 74 | assert.Equal(t, "swaggest/usecase_test.Foo.Baz", u.Name()) 75 | assert.Equal(t, "Foo Baz", u.Title()) 76 | 77 | u = fooBar() 78 | assert.Equal(t, "swaggest/usecase_test.fooBar", u.Name()) 79 | assert.Equal(t, "Foo Bar", u.Title()) 80 | } 81 | 82 | func fooBar() usecase.IOInteractor { 83 | return usecase.NewIOI(nil, nil, func(ctx context.Context, input, output interface{}) error { 84 | return nil 85 | }) 86 | } 87 | -------------------------------------------------------------------------------- /output.go: -------------------------------------------------------------------------------- 1 | package usecase 2 | 3 | import "io" 4 | 5 | // OutputWithWriter defines output with streaming writer. 6 | type OutputWithWriter interface { 7 | SetWriter(w io.Writer) 8 | } 9 | 10 | // OutputWithEmbeddedWriter implements streaming use case output. 11 | type OutputWithEmbeddedWriter struct { 12 | io.Writer 13 | } 14 | 15 | // SetWriter implements OutputWithWriter. 16 | func (o *OutputWithEmbeddedWriter) SetWriter(w io.Writer) { 17 | o.Writer = w 18 | } 19 | 20 | // OutputWithNoContent is embeddable structure to provide conditional output discard state. 21 | type OutputWithNoContent struct { 22 | disabled bool 23 | } 24 | 25 | // SetNoContent controls output discard state. 26 | func (o *OutputWithNoContent) SetNoContent(enabled bool) { 27 | o.disabled = !enabled 28 | } 29 | 30 | // NoContent returns output discard state. 31 | func (o OutputWithNoContent) NoContent() bool { 32 | return !o.disabled 33 | } 34 | -------------------------------------------------------------------------------- /output_test.go: -------------------------------------------------------------------------------- 1 | package usecase_test 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | "github.com/swaggest/usecase" 10 | ) 11 | 12 | func TestOutputWithEmbeddedWriter_SetWriter(t *testing.T) { 13 | w := bytes.NewBuffer(nil) 14 | o := usecase.OutputWithEmbeddedWriter{} 15 | o.SetWriter(w) 16 | 17 | _, err := o.Write([]byte("hello")) 18 | require.NoError(t, err) 19 | 20 | assert.Equal(t, "hello", w.String()) 21 | } 22 | 23 | func TestOutputWithNoContent_NoContent(t *testing.T) { 24 | o := usecase.OutputWithNoContent{} 25 | assert.True(t, o.NoContent()) 26 | o.SetNoContent(false) 27 | assert.False(t, o.NoContent()) 28 | } 29 | -------------------------------------------------------------------------------- /status/doc.go: -------------------------------------------------------------------------------- 1 | // Package status defines a list of canonical statuses. 2 | package status 3 | -------------------------------------------------------------------------------- /status/error.go: -------------------------------------------------------------------------------- 1 | package status 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | ) 7 | 8 | var codeToMsg = func() map[Code]string { 9 | res := make(map[Code]string, len(strToCode)) 10 | for str, code := range strToCode { 11 | res[code] = strings.ToLower(strings.ReplaceAll(str, "_", " ")) 12 | } 13 | 14 | return res 15 | }() 16 | 17 | // Error returns string value of status code. 18 | func (c Code) Error() string { 19 | return codeToMsg[c] 20 | } 21 | 22 | // Is implements interface for errors.Is. 23 | func (c Code) Is(target error) bool { 24 | return target == c //nolint:goerr113 // Target is expected to be plain status error. 25 | } 26 | 27 | type errorWithStatus struct { 28 | err error 29 | code Code 30 | } 31 | 32 | func (e errorWithStatus) Error() string { 33 | return codeToMsg[e.code] + ": " + e.err.Error() 34 | } 35 | 36 | func (e errorWithStatus) Unwrap() error { 37 | return e.err 38 | } 39 | 40 | func (e errorWithStatus) Is(target error) bool { 41 | if target == e.code { //nolint:goerr113 // Target is expected to be plain status error. 42 | return true 43 | } 44 | 45 | return errors.Is(e.err, target) 46 | } 47 | 48 | func (e errorWithStatus) Status() Code { 49 | return e.code 50 | } 51 | 52 | // Wrap adds canonical status to error. 53 | func Wrap(err error, code Code) error { 54 | if err == nil { 55 | return code 56 | } 57 | 58 | return errorWithStatus{ 59 | err: err, 60 | code: code, 61 | } 62 | } 63 | 64 | type codeWithDescription struct { 65 | Code 66 | desc string 67 | } 68 | 69 | func (e codeWithDescription) Description() string { 70 | return e.desc 71 | } 72 | 73 | // WithDescription augments status Code with textual description. 74 | func WithDescription(code Code, description string) error { 75 | e := codeWithDescription{} 76 | e.Code = code 77 | e.desc = description 78 | 79 | return e 80 | } 81 | -------------------------------------------------------------------------------- /status/error_test.go: -------------------------------------------------------------------------------- 1 | package status_test 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/swaggest/usecase/status" 9 | ) 10 | 11 | func TestWrap(t *testing.T) { 12 | err := status.Wrap(errors.New("failed"), status.AlreadyExists) 13 | assert.EqualError(t, err, "already exists: failed") 14 | assert.True(t, errors.Is(err, status.AlreadyExists)) 15 | assert.False(t, errors.Is(err, status.NotFound)) 16 | assert.EqualError(t, err.(interface{ Unwrap() error }).Unwrap(), "failed") 17 | assert.Equal(t, status.AlreadyExists, err.(interface{ Status() status.Code }).Status()) 18 | } 19 | 20 | func TestCode_Error(t *testing.T) { 21 | assert.Equal(t, "deadline exceeded", status.DeadlineExceeded.Error()) 22 | } 23 | 24 | func TestWithDescription(t *testing.T) { 25 | err := status.WithDescription(status.AlreadyExists, "This is a description.") 26 | assert.EqualError(t, err, "already exists") 27 | assert.True(t, errors.Is(err, status.AlreadyExists)) 28 | assert.False(t, errors.Is(err, status.NotFound)) 29 | 30 | d, ok := err.(interface { 31 | Description() string 32 | }) 33 | assert.True(t, ok) 34 | assert.Equal(t, "This is a description.", d.Description()) 35 | } 36 | -------------------------------------------------------------------------------- /status/status.go: -------------------------------------------------------------------------------- 1 | package status 2 | 3 | // Code defines the canonical status codes used by application use cases. 4 | // 5 | // Should be consistent across all use cases to be expressible with different transport protocols. 6 | // Status codes are following gRPC conventions for RPC API: 7 | // - https://github.com/grpc/grpc/blob/master/doc/statuscodes.md 8 | // - https://github.com/grpc/grpc-go/blob/master/codes/codes.go 9 | // - https://github.com/googleapis/googleapis/blob/master/google/rpc/code.proto 10 | type Code int 11 | 12 | const ( 13 | // OK is returned on success. 14 | // 15 | // HTTP Mapping: 200 OK. 16 | OK Code = 0 17 | 18 | // Canceled indicates the operation was canceled (typically by the caller). 19 | // 20 | // HTTP Mapping: 499 Client Closed Request. 21 | Canceled Code = 1 22 | 23 | // Unknown error. An example of where this error may be returned is 24 | // if a Status value received from another address space belongs to 25 | // an error-space that is not known in this address space. Also 26 | // errors raised by APIs that do not return enough error information 27 | // may be converted to this error. 28 | // 29 | // HTTP Mapping: 500 Internal Server Error. 30 | Unknown Code = 2 31 | 32 | // InvalidArgument indicates client specified an invalid argument. 33 | // Note that this differs from FailedPrecondition. It indicates arguments 34 | // that are problematic regardless of the state of the system 35 | // (e.g., a malformed file name). 36 | // 37 | // HTTP Mapping: 400 Bad Request. 38 | InvalidArgument Code = 3 39 | 40 | // DeadlineExceeded means operation expired before completion. 41 | // For operations that change the state of the system, this error may be 42 | // returned even if the operation has completed successfully. For 43 | // example, a successful response from a server could have been delayed 44 | // long enough for the deadline to expire. 45 | // 46 | // HTTP Mapping: 504 Gateway Timeout. 47 | DeadlineExceeded Code = 4 48 | 49 | // NotFound means some requested entity (e.g., file or directory) was 50 | // not found. 51 | // 52 | // HTTP Mapping: 404 Not Found. 53 | NotFound Code = 5 54 | 55 | // AlreadyExists means an attempt to create an entity failed because one 56 | // already exists. 57 | // 58 | // HTTP Mapping: 409 Conflict. 59 | AlreadyExists Code = 6 60 | 61 | // PermissionDenied indicates the caller does not have permission to 62 | // execute the specified operation. It must not be used for rejections 63 | // caused by exhausting some resource (use ResourceExhausted 64 | // instead for those errors). It must not be 65 | // used if the caller cannot be identified (use Unauthenticated 66 | // instead for those errors). 67 | // 68 | // HTTP Mapping: 403 Forbidden. 69 | PermissionDenied Code = 7 70 | 71 | // ResourceExhausted indicates some resource has been exhausted, perhaps 72 | // a per-user quota, or perhaps the entire file system is out of space. 73 | // 74 | // HTTP Mapping: 429 Too Many Requests. 75 | ResourceExhausted Code = 8 76 | 77 | // FailedPrecondition indicates operation was rejected because the 78 | // system is not in a state required for the operation's execution. 79 | // For example, directory to be deleted may be non-empty, an rmdir 80 | // operation is applied to a non-directory, etc. 81 | // 82 | // A litmus test that may help a service implementor in deciding 83 | // between FailedPrecondition, Aborted, and Unavailable: 84 | // (a) Use Unavailable if the client can retry just the failing call. 85 | // (b) Use Aborted if the client should retry at a higher-level 86 | // (e.g., restarting a read-modify-write sequence). 87 | // (c) Use FailedPrecondition if the client should not retry until 88 | // the system state has been explicitly fixed. E.g., if an "rmdir" 89 | // fails because the directory is non-empty, FailedPrecondition 90 | // should be returned since the client should not retry unless 91 | // they have first fixed up the directory by deleting files from it. 92 | // (d) Use FailedPrecondition if the client performs conditional 93 | // REST Get/Update/Delete on a resource and the resource on the 94 | // server does not match the condition. E.g., conflicting 95 | // read-modify-write on the same resource. 96 | // 97 | // HTTP Mapping: 400 Bad Request. 98 | FailedPrecondition Code = 9 99 | 100 | // Aborted indicates the operation was aborted, typically due to a 101 | // concurrency issue like sequencer check failures, transaction aborts, 102 | // etc. 103 | // 104 | // See litmus test above for deciding between FailedPrecondition, 105 | // Aborted, and Unavailable. 106 | // 107 | // HTTP Mapping: 409 Conflict. 108 | Aborted Code = 10 109 | 110 | // OutOfRange means operation was attempted past the valid range. 111 | // E.g., seeking or reading past end of file. 112 | // 113 | // Unlike InvalidArgument, this error indicates a problem that may 114 | // be fixed if the system state changes. For example, a 32-bit file 115 | // system will generate InvalidArgument if asked to read at an 116 | // offset that is not in the range [0,2^32-1], but it will generate 117 | // OutOfRange if asked to read from an offset past the current 118 | // file size. 119 | // 120 | // There is a fair bit of overlap between FailedPrecondition and 121 | // OutOfRange. We recommend using OutOfRange (the more specific 122 | // error) when it applies so that callers who are iterating through 123 | // a space can easily look for an OutOfRange error to detect when 124 | // they are done. 125 | // 126 | // HTTP Mapping: 400 Bad Request. 127 | OutOfRange Code = 11 128 | 129 | // Unimplemented indicates operation is not implemented or not 130 | // supported/enabled in this service. 131 | // 132 | // HTTP Mapping: 501 Not Implemented. 133 | Unimplemented Code = 12 134 | 135 | // Internal errors. Means some invariants expected by underlying 136 | // system has been broken. If you see one of these errors, 137 | // something is very broken. 138 | // 139 | // HTTP Mapping: 500 Internal Server Error. 140 | Internal Code = 13 141 | 142 | // Unavailable indicates the service is currently unavailable. 143 | // This is a most likely a transient condition and may be corrected 144 | // by retrying with a backoff. 145 | // 146 | // See litmus test above for deciding between FailedPrecondition, 147 | // Aborted, and Unavailable. 148 | // 149 | // HTTP Mapping: 503 Service Unavailable. 150 | Unavailable Code = 14 151 | 152 | // DataLoss indicates unrecoverable data loss or corruption. 153 | // 154 | // HTTP Mapping: 500 Internal Server Error. 155 | DataLoss Code = 15 156 | 157 | // Unauthenticated indicates the request does not have valid 158 | // authentication credentials for the operation. 159 | // 160 | // HTTP Mapping: 401 Unauthorized. 161 | Unauthenticated Code = 16 162 | ) 163 | 164 | var ( 165 | strToCode = map[string]Code{ 166 | `OK`: OK, 167 | //nolint:misspell // Due to historical reasons `CANCELLED` should be spelled with double L. 168 | `CANCELLED`: Canceled, 169 | `UNKNOWN`: Unknown, 170 | `INVALID_ARGUMENT`: InvalidArgument, 171 | `DEADLINE_EXCEEDED`: DeadlineExceeded, 172 | `NOT_FOUND`: NotFound, 173 | `ALREADY_EXISTS`: AlreadyExists, 174 | `PERMISSION_DENIED`: PermissionDenied, 175 | `RESOURCE_EXHAUSTED`: ResourceExhausted, 176 | `FAILED_PRECONDITION`: FailedPrecondition, 177 | `ABORTED`: Aborted, 178 | `OUT_OF_RANGE`: OutOfRange, 179 | `UNIMPLEMENTED`: Unimplemented, 180 | `INTERNAL`: Internal, 181 | `UNAVAILABLE`: Unavailable, 182 | `DATA_LOSS`: DataLoss, 183 | `UNAUTHENTICATED`: Unauthenticated, 184 | } 185 | 186 | codeToStr = func() map[Code]string { 187 | res := make(map[Code]string, len(strToCode)) 188 | for str, code := range strToCode { 189 | res[code] = str 190 | } 191 | 192 | return res 193 | }() 194 | ) 195 | 196 | // String returns string value of status code. 197 | func (c Code) String() string { 198 | return codeToStr[c] 199 | } 200 | 201 | // Status is an accessor. 202 | func (c Code) Status() Code { 203 | return c 204 | } 205 | -------------------------------------------------------------------------------- /status/status_test.go: -------------------------------------------------------------------------------- 1 | package status_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/swaggest/usecase/status" 8 | ) 9 | 10 | func TestCode_Status(t *testing.T) { 11 | assert.Equal(t, "FAILED_PRECONDITION", status.FailedPrecondition.String()) 12 | assert.Equal(t, status.FailedPrecondition, status.FailedPrecondition.Status()) 13 | } 14 | -------------------------------------------------------------------------------- /wrap.go: -------------------------------------------------------------------------------- 1 | package usecase 2 | 3 | import ( 4 | "context" 5 | "reflect" 6 | ) 7 | 8 | // Middleware creates decorated use case interactor. 9 | type Middleware interface { 10 | Wrap(interactor Interactor) Interactor 11 | } 12 | 13 | // ErrorCatcher is a use case middleware that collects non empty errors. 14 | type ErrorCatcher func(ctx context.Context, input interface{}, err error) 15 | 16 | // Wrap implements Middleware. 17 | func (e ErrorCatcher) Wrap(u Interactor) Interactor { 18 | return &wrappedInteractor{ 19 | Interactor: Interact(func(ctx context.Context, input, output interface{}) error { 20 | err := u.Interact(ctx, input, output) 21 | if err != nil { 22 | e(ctx, input, err) 23 | } 24 | 25 | return err 26 | }), 27 | wrapped: u, 28 | } 29 | } 30 | 31 | // MiddlewareFunc makes Middleware from function. 32 | type MiddlewareFunc func(next Interactor) Interactor 33 | 34 | // Wrap decorates use case interactor. 35 | func (mwf MiddlewareFunc) Wrap(interactor Interactor) Interactor { 36 | return mwf(interactor) 37 | } 38 | 39 | // Wrap decorates Interactor with Middlewares. 40 | // 41 | // Having arguments i, mw1, mw2 the order of invocation is: mw1, mw2, i, mw2, mw1. 42 | // Middleware mw1 can find behaviors of mw2 with As, but not vice versa. 43 | func Wrap(interactor Interactor, mw ...Middleware) Interactor { 44 | for i := len(mw) - 1; i >= 0; i-- { 45 | w := mw[i].Wrap(interactor) 46 | if w != nil { 47 | interactor = &wrappedInteractor{ 48 | Interactor: w, 49 | wrapped: interactor, 50 | } 51 | } 52 | } 53 | 54 | return interactor 55 | } 56 | 57 | // As finds the first Interactor in Interactor's chain that matches target, and if so, sets 58 | // target to that Interactor value and returns true. 59 | // 60 | // An Interactor matches target if the Interactor's concrete value is assignable to the value 61 | // pointed to by target. 62 | // 63 | // As will panic if target is not a non-nil pointer to either a type that implements 64 | // Interactor, or to any interface type. 65 | func As(interactor Interactor, target interface{}) bool { 66 | if interactor == nil { 67 | return false 68 | } 69 | 70 | if target == nil { 71 | panic("target cannot be nil") 72 | } 73 | 74 | val := reflect.ValueOf(target) 75 | typ := val.Type() 76 | 77 | if typ.Kind() != reflect.Ptr || val.IsNil() { 78 | panic("target must be a non-nil pointer") 79 | } 80 | 81 | if e := typ.Elem(); e.Kind() != reflect.Interface { 82 | panic("*target must be interface") 83 | } 84 | 85 | targetType := typ.Elem() 86 | 87 | for { 88 | wrap, isWrap := interactor.(*wrappedInteractor) 89 | 90 | if isWrap { 91 | interactor = wrap.Interactor 92 | } 93 | 94 | if reflect.TypeOf(interactor).AssignableTo(targetType) { 95 | val.Elem().Set(reflect.ValueOf(interactor)) 96 | 97 | return true 98 | } 99 | 100 | if !isWrap { 101 | break 102 | } 103 | 104 | interactor = wrap.wrapped 105 | } 106 | 107 | return false 108 | } 109 | 110 | type wrappedInteractor struct { 111 | Interactor 112 | wrapped Interactor 113 | } 114 | -------------------------------------------------------------------------------- /wrap_test.go: -------------------------------------------------------------------------------- 1 | package usecase_test 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/swaggest/usecase" 10 | ) 11 | 12 | func TestWrap(t *testing.T) { 13 | var ( 14 | invocationOrder []string 15 | withInput usecase.HasInputPort 16 | withOutput usecase.HasOutputPort 17 | ) 18 | 19 | f := func(next usecase.Interactor, name string) usecase.Interactor { 20 | return usecase.Interact(func(ctx context.Context, input, output interface{}) error { 21 | invocationOrder = append(invocationOrder, name+" start") 22 | err := next.Interact(ctx, input, output) 23 | invocationOrder = append(invocationOrder, name+" end") 24 | 25 | return err 26 | }) 27 | } 28 | 29 | mw1 := usecase.MiddlewareFunc(func(next usecase.Interactor) usecase.Interactor { 30 | assert.False(t, usecase.As(next, &withInput)) 31 | assert.True(t, usecase.As(next, &withOutput)) 32 | 33 | u := struct { 34 | usecase.HasInputPort 35 | usecase.Interactor 36 | }{ 37 | HasInputPort: usecase.WithInput{Input: new(string)}, 38 | Interactor: f(next, "mw1"), 39 | } 40 | 41 | return u 42 | }) 43 | 44 | mw2 := usecase.MiddlewareFunc(func(next usecase.Interactor) usecase.Interactor { 45 | assert.False(t, usecase.As(next, &withInput)) 46 | assert.False(t, usecase.As(next, &withOutput)) 47 | 48 | u := struct { 49 | usecase.HasOutputPort 50 | usecase.Interactor 51 | }{ 52 | HasOutputPort: usecase.WithOutput{Output: new(string)}, 53 | Interactor: f(next, "mw2"), 54 | } 55 | 56 | return u 57 | }) 58 | 59 | i := usecase.Wrap(usecase.Interact(func(ctx context.Context, input, output interface{}) error { 60 | invocationOrder = append(invocationOrder, "interaction") 61 | 62 | return nil 63 | }), mw1, mw2) 64 | err := i.Interact(context.Background(), nil, nil) 65 | 66 | assert.NoError(t, err) 67 | assert.Equal(t, []string{ 68 | "mw1 start", "mw2 start", "interaction", "mw2 end", "mw1 end", 69 | }, invocationOrder) 70 | assert.True(t, usecase.As(i, &withInput)) 71 | assert.True(t, usecase.As(i, &withOutput)) 72 | } 73 | 74 | func TestAs(t *testing.T) { 75 | type Response struct { 76 | Name string `json:"name"` 77 | } 78 | 79 | u := struct { 80 | usecase.Interactor 81 | usecase.HasInputPort 82 | usecase.HasOutputPort 83 | }{ 84 | Interactor: usecase.Interact(func(ctx context.Context, input, output interface{}) error { 85 | o, ok := output.(*Response) 86 | assert.True(t, ok) 87 | 88 | o.Name = "Jane" 89 | 90 | return nil 91 | }), 92 | HasOutputPort: usecase.WithOutput{ 93 | Output: Response{}, 94 | }, 95 | } 96 | 97 | var ( 98 | withOutput usecase.HasOutputPort 99 | withInput usecase.HasInputPort 100 | ) 101 | 102 | assert.True(t, usecase.As(u, &withInput)) 103 | assert.True(t, usecase.As(u, &withOutput)) 104 | } 105 | 106 | func TestAs_panics(t *testing.T) { 107 | u := struct { 108 | usecase.Interactor 109 | usecase.Info 110 | }{} 111 | 112 | // target cannot be nil. 113 | assert.Panics(t, func() { 114 | usecase.As(u, nil) 115 | }) 116 | 117 | // target must be a non-nil pointer. 118 | assert.Panics(t, func() { 119 | usecase.As(u, 123) 120 | }) 121 | 122 | // *target must be interface. 123 | assert.Panics(t, func() { 124 | usecase.As(u, &usecase.Info{}) 125 | }) 126 | } 127 | 128 | func TestErrorCatcher_Wrap(t *testing.T) { 129 | u := usecase.NewIOI(nil, nil, func(ctx context.Context, input, output interface{}) error { 130 | return errors.New("failed") 131 | }) 132 | 133 | called := false 134 | uw := usecase.Wrap(u, usecase.ErrorCatcher(func(ctx context.Context, input interface{}, err error) { 135 | called = true 136 | assert.EqualError(t, err, "failed") 137 | })) 138 | 139 | assert.EqualError(t, uw.Interact(context.Background(), nil, nil), "failed") 140 | assert.True(t, called) 141 | } 142 | --------------------------------------------------------------------------------