feat: initial version
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.go]
|
||||
indent_style = tab
|
||||
indent_size = 2
|
||||
@@ -0,0 +1,2 @@
|
||||
.idea
|
||||
/release
|
||||
@@ -0,0 +1,36 @@
|
||||
include:
|
||||
- template: 'Workflows/MergeRequest-Pipelines.gitlab-ci.yml'
|
||||
- project: unboundsoftware/ci-templates
|
||||
file: Release.gitlab-ci.yml
|
||||
- project: unboundsoftware/ci-templates
|
||||
file: Pre-Commit-Go.gitlab-ci.yml
|
||||
|
||||
image: amd64/golang:1.24.1@sha256:5ecf3334c3970cf435bf821e0b7c48276ca8f00459fbfa107f4702e59e011d97
|
||||
|
||||
stages:
|
||||
- deps
|
||||
- test
|
||||
|
||||
deps:
|
||||
stage: deps
|
||||
script:
|
||||
- go mod download
|
||||
|
||||
test:
|
||||
stage: test
|
||||
dependencies:
|
||||
- deps
|
||||
script:
|
||||
- CGO_ENABLED=1 go test -mod=readonly -race -coverprofile=coverage.txt -covermode=atomic -coverpkg=$(go list ./... | tr '\n' , | sed 's/,$//') ./...
|
||||
- go tool cover -html=coverage.txt -o coverage.html
|
||||
- go tool cover -func=coverage.txt
|
||||
- curl -Os https://uploader.codecov.io/latest/linux/codecov
|
||||
- chmod +x codecov
|
||||
- ./codecov -t ${CODECOV_TOKEN} -R $CI_PROJECT_DIR -C $CI_COMMIT_SHA -r $CI_PROJECT_PATH
|
||||
|
||||
vulnerabilities:
|
||||
stage: test
|
||||
image: amd64/golang:1.24.1@sha256:5ecf3334c3970cf435bf821e0b7c48276ca8f00459fbfa107f4702e59e011d97
|
||||
script:
|
||||
- go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
- govulncheck ./...
|
||||
@@ -0,0 +1,22 @@
|
||||
version: "2"
|
||||
run:
|
||||
allow-parallel-runners: true
|
||||
linters:
|
||||
exclusions:
|
||||
generated: lax
|
||||
presets:
|
||||
- comments
|
||||
- common-false-positives
|
||||
- legacy
|
||||
- std-error-handling
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
formatters:
|
||||
exclusions:
|
||||
generated: lax
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
@@ -0,0 +1,46 @@
|
||||
# See https://pre-commit.com for more information
|
||||
# See https://pre-commit.com/hooks.html for more hooks
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
- id: check-yaml
|
||||
args:
|
||||
- --allow-multiple-documents
|
||||
- id: check-added-large-files
|
||||
- repo: https://gitlab.com/devopshq/gitlab-ci-linter
|
||||
rev: v1.0.6
|
||||
hooks:
|
||||
- id: gitlab-ci-linter
|
||||
args:
|
||||
- --project
|
||||
- unboundsoftware/shiny/sentrysetup
|
||||
- repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook
|
||||
rev: v9.22.0
|
||||
hooks:
|
||||
- id: commitlint
|
||||
stages: [ commit-msg ]
|
||||
additional_dependencies: [ '@commitlint/config-conventional' ]
|
||||
- repo: https://github.com/dnephin/pre-commit-golang
|
||||
rev: v0.5.1
|
||||
hooks:
|
||||
- id: go-mod-tidy
|
||||
- id: go-imports
|
||||
args:
|
||||
- -local
|
||||
- gitlab.com/unboundsoftware/shiny/presenter
|
||||
- repo: https://github.com/lietu/go-pre-commit
|
||||
rev: v0.1.0
|
||||
hooks:
|
||||
- id: go-test
|
||||
- id: gofumpt
|
||||
- repo: https://github.com/golangci/golangci-lint
|
||||
rev: v2.0.2
|
||||
hooks:
|
||||
- id: golangci-lint-full
|
||||
- repo: https://github.com/gitleaks/gitleaks
|
||||
rev: v8.24.2
|
||||
hooks:
|
||||
- id: gitleaks
|
||||
@@ -0,0 +1,6 @@
|
||||
# Shiny global error presenter
|
||||
|
||||
GraphQL global error presenter handling coded errors.
|
||||
|
||||
[](https://gitlab.com/unboundsoftware/shiny/presenter/commits/main)
|
||||
[](https://codecov.io/gl/unboundsoftware:shiny/presenter)
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
# git-cliff ~ default configuration file
|
||||
# https://git-cliff.org/docs/configuration
|
||||
#
|
||||
# Lines starting with "#" are comments.
|
||||
# Configuration options are organized into tables and keys.
|
||||
# See documentation for more information on available options.
|
||||
|
||||
[bump]
|
||||
initial_tag = "v0.0.1"
|
||||
|
||||
[changelog]
|
||||
# template for the changelog header
|
||||
header = """
|
||||
# Changelog\n
|
||||
All notable changes to this project will be documented in this file.\n
|
||||
"""
|
||||
# template for the changelog body
|
||||
# https://keats.github.io/tera/docs/#introduction
|
||||
body = """
|
||||
{% if version %}\
|
||||
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
|
||||
{% else %}\
|
||||
## [unreleased]
|
||||
{% endif %}\
|
||||
{% for group, commits in commits | group_by(attribute="group") %}
|
||||
### {{ group | striptags | trim | upper_first }}
|
||||
{% for commit in commits %}
|
||||
- {% if commit.scope %}*({{ commit.scope }})* {% endif %}\
|
||||
{% if commit.breaking %}[**breaking**] {% endif %}\
|
||||
{{ commit.message | upper_first }}\
|
||||
{% endfor %}
|
||||
{% endfor %}\n
|
||||
"""
|
||||
# template for the changelog footer
|
||||
footer = """
|
||||
<!-- generated by git-cliff -->
|
||||
"""
|
||||
# remove the leading and trailing s
|
||||
trim = true
|
||||
# postprocessors
|
||||
postprocessors = [
|
||||
# { pattern = '<REPO>', replace = "https://github.com/orhun/git-cliff" }, # replace repository URL
|
||||
]
|
||||
# render body even when there are no releases to process
|
||||
# render_always = true
|
||||
# output file path
|
||||
# output = "test.md"
|
||||
|
||||
[git]
|
||||
# parse the commits based on https://www.conventionalcommits.org
|
||||
conventional_commits = true
|
||||
# filter out the commits that are not conventional
|
||||
filter_unconventional = true
|
||||
# process each line of a commit as an individual commit
|
||||
split_commits = false
|
||||
# regex for preprocessing the commit messages
|
||||
commit_preprocessors = [
|
||||
# Replace issue numbers
|
||||
#{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](<REPO>/issues/${2}))"},
|
||||
# Check spelling of the commit with https://github.com/crate-ci/typos
|
||||
# If the spelling is incorrect, it will be automatically fixed.
|
||||
#{ pattern = '.*', replace_command = 'typos --write-changes -' },
|
||||
]
|
||||
# regex for parsing and grouping commits
|
||||
commit_parsers = [
|
||||
{ message = "^feat", group = "<!-- 0 -->🚀 Features" },
|
||||
{ message = "^fix", group = "<!-- 1 -->🐛 Bug Fixes" },
|
||||
{ message = "^doc", group = "<!-- 3 -->📚 Documentation" },
|
||||
{ message = "^perf", group = "<!-- 4 -->⚡ Performance" },
|
||||
{ message = "^refactor", group = "<!-- 2 -->🚜 Refactor" },
|
||||
{ message = "^style", group = "<!-- 5 -->🎨 Styling" },
|
||||
{ message = "^test", group = "<!-- 6 -->🧪 Testing" },
|
||||
{ message = "^chore\\(release\\): prepare for", skip = true },
|
||||
{ message = "^chore\\(deps.*\\)", skip = true },
|
||||
{ message = "^chore\\(pr\\)", skip = true },
|
||||
{ message = "^chore\\(pull\\)", skip = true },
|
||||
{ message = "^chore|^ci", group = "<!-- 7 -->⚙️ Miscellaneous Tasks" },
|
||||
{ body = ".*security", group = "<!-- 8 -->🛡️ Security" },
|
||||
{ message = "^revert", group = "<!-- 9 -->◀️ Revert" },
|
||||
]
|
||||
# filter out the commits that are not matched by commit parsers
|
||||
filter_commits = false
|
||||
# sort the tags topologically
|
||||
topo_order = false
|
||||
# sort the commits inside sections by oldest/newest order
|
||||
sort_commits = "oldest"
|
||||
tag_pattern = "v[0-9]+\\.[0-9]+\\.[0-9]+"
|
||||
@@ -0,0 +1,96 @@
|
||||
package presenter
|
||||
|
||||
import "errors"
|
||||
|
||||
type (
|
||||
Code string
|
||||
Entity string
|
||||
)
|
||||
|
||||
const (
|
||||
CodeConflict = Code("CONFLICT")
|
||||
CodeNotFound = Code("NOT_FOUND")
|
||||
CodePreconditionFailed = Code("PRECONDITION_FAILED")
|
||||
)
|
||||
|
||||
const (
|
||||
EntityCompany = Entity("COMPANY")
|
||||
EntityConsumer = Entity("CONSUMER")
|
||||
EntityAddress = Entity("ADDRESS")
|
||||
EntityContactPerson = Entity("CONTACT_PERSON")
|
||||
EntityActivity = Entity("ACTIVITY")
|
||||
EntityPrice = Entity("PRICE")
|
||||
EntityEmployee = Entity("EMPLOYEE")
|
||||
EntityContract = Entity("CONTRACT")
|
||||
EntityEmploymentAgreement = Entity("EMPLOYMENT_AGREEMENT")
|
||||
EntityInvoiceBasis = Entity("INVOICE_BASIS")
|
||||
EntityInvoice = Entity("INVOICE")
|
||||
EntitySupplier = Entity("SUPPLIER")
|
||||
EntitySupplierInvoice = Entity("SUPPLIER_INVOICE")
|
||||
EntityTimeEntry = Entity("TIME_ENTRY")
|
||||
EntityEntrySeries = Entity("ENTRY_SERIES")
|
||||
EntityFiscalYear = Entity("FISCAL_YEAR")
|
||||
EntityAccountClass = Entity("ACCOUNT_CLASS")
|
||||
EntityAccountGroup = Entity("ACCOUNT_GROUP")
|
||||
EntityAccount = Entity("ACCOUNT")
|
||||
EntityTag = Entity("TAG")
|
||||
EntityEntry = Entity("ENTRY")
|
||||
EntityEntryBasis = Entity("ENTRY_BASIS")
|
||||
)
|
||||
|
||||
type CodedError struct {
|
||||
Message string
|
||||
Code Code
|
||||
Entity Entity
|
||||
Params map[string]string
|
||||
}
|
||||
|
||||
func NewCodedError(message string, code Code, entity Entity) CodedError {
|
||||
return CodedError{
|
||||
Message: message,
|
||||
Code: code,
|
||||
Entity: entity,
|
||||
Params: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
func (c CodedError) Error() string {
|
||||
return c.Message
|
||||
}
|
||||
|
||||
func (c CodedError) Is(err error) bool {
|
||||
var e CodedError
|
||||
if errors.As(err, &e) {
|
||||
return c.Code == e.Code && c.Entity == e.Entity && c.Message == e.Message && mapEqual(c.Params, e.Params)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func mapEqual(m1, m2 map[string]string) bool {
|
||||
if len(m1) != len(m2) {
|
||||
return false
|
||||
}
|
||||
for k1, v1 := range m1 {
|
||||
v2, exists := m2[k1]
|
||||
if !exists || v1 != v2 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (c CodedError) WithParam(key, value string) CodedError {
|
||||
params := make(map[string]string)
|
||||
for k, v := range c.Params {
|
||||
params[k] = v
|
||||
}
|
||||
params[key] = value
|
||||
return CodedError{
|
||||
Message: c.Message,
|
||||
Code: c.Code,
|
||||
Entity: c.Entity,
|
||||
Params: params,
|
||||
}
|
||||
}
|
||||
|
||||
var _ error = CodedError{}
|
||||
@@ -0,0 +1,24 @@
|
||||
package presenter_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"gitlab.com/unboundsoftware/shiny/presenter"
|
||||
)
|
||||
|
||||
func TestCodedError_ErrorIs(t *testing.T) {
|
||||
require.True(t, errors.Is(ErrActivityNotFound, ErrActivityNotFound))
|
||||
require.False(t, errors.Is(ErrActivityNotFound, fmt.Errorf("other")))
|
||||
require.False(t, errors.Is(ErrActivityNotFound, ErrEntryNotFound))
|
||||
require.False(t, errors.Is(ErrActivityNotFound, ErrActivityNotFound.WithParam("some", "value")))
|
||||
require.False(t, errors.Is(ErrActivityNotFound.WithParam("some", "other"), ErrActivityNotFound.WithParam("some", "value")))
|
||||
}
|
||||
|
||||
var (
|
||||
ErrActivityNotFound = presenter.NewCodedError("activity not found", presenter.CodeNotFound, presenter.EntityActivity)
|
||||
ErrEntryNotFound = presenter.NewCodedError("entry not found", presenter.CodeNotFound, presenter.EntityEntry)
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
module gitlab.com/unboundsoftware/shiny/presenter
|
||||
|
||||
go 1.24.1
|
||||
|
||||
require (
|
||||
github.com/99designs/gqlgen v0.17.70
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/vektah/gqlparser/v2 v2.5.23
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/sosodev/duration v1.3.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
github.com/99designs/gqlgen v0.17.70 h1:xgLIgQuG+Q2L/AE9cW595CT7xCWCe/bpPIFGSfsGSGs=
|
||||
github.com/99designs/gqlgen v0.17.70/go.mod h1:fvCiqQAu2VLhKXez2xFvLmE47QgAPf/KTPN5XQ4rsHQ=
|
||||
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
|
||||
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
|
||||
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
|
||||
github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4=
|
||||
github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/vektah/gqlparser/v2 v2.5.23 h1:PurJ9wpgEVB7tty1seRUwkIDa/QH5RzkzraiKIjKLfA=
|
||||
github.com/vektah/gqlparser/v2 v2.5.23/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,58 @@
|
||||
package presenter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/99designs/gqlgen/graphql"
|
||||
"github.com/vektah/gqlparser/v2/gqlerror"
|
||||
)
|
||||
|
||||
func New[C ~string, E ~string](logger *slog.Logger, codes []C, entities []E, internalErrorCode C) func(ctx context.Context, e error) *gqlerror.Error {
|
||||
return func(ctx context.Context, e error) *gqlerror.Error {
|
||||
err := graphql.DefaultErrorPresenter(ctx, e)
|
||||
var codedError CodedError
|
||||
if errors.As(e, &codedError) {
|
||||
code := toModelErrorCode(codedError.Code, codes, internalErrorCode)
|
||||
errorEntity := toModelErrorEntity(codedError.Entity, entities)
|
||||
extensions := map[string]interface{}{"code": code}
|
||||
if len(errorEntity) > 0 {
|
||||
extensions["errorEntity"] = errorEntity
|
||||
}
|
||||
if len(codedError.Params) > 0 {
|
||||
extensions["params"] = codedError.Params
|
||||
}
|
||||
err.Extensions = extensions
|
||||
} else {
|
||||
err.Extensions = map[string]interface{}{"code": internalErrorCode}
|
||||
}
|
||||
if logError(e) {
|
||||
logger.ErrorContext(ctx, fmt.Sprintf("%v", e))
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func logError(err error) bool {
|
||||
return !errors.Is(err, context.Canceled)
|
||||
}
|
||||
|
||||
func toModelErrorCode[C ~string](code Code, codes []C, internalErrorCode C) C {
|
||||
for _, c := range codes {
|
||||
if string(c) == string(code) {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return internalErrorCode
|
||||
}
|
||||
|
||||
func toModelErrorEntity[E ~string](entity Entity, entities []E) E {
|
||||
for _, e := range entities {
|
||||
if string(e) == string(entity) {
|
||||
return e
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package presenter_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/vektah/gqlparser/v2/gqlerror"
|
||||
|
||||
"gitlab.com/unboundsoftware/shiny/presenter"
|
||||
)
|
||||
|
||||
func Test_globalErrorPresenter(t *testing.T) {
|
||||
type args struct {
|
||||
ctx context.Context
|
||||
err error
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want *gqlerror.Error
|
||||
logged []string
|
||||
}{
|
||||
{
|
||||
name: "coded error",
|
||||
args: args{
|
||||
ctx: context.Background(),
|
||||
err: ErrEntryNotFound,
|
||||
},
|
||||
want: wrap(ErrEntryNotFound, Code("NOT_FOUND"), ErrorEntity("ENTRY")),
|
||||
logged: []string{"level=ERROR msg=\"entry not found\""},
|
||||
},
|
||||
{
|
||||
name: "coded error with param",
|
||||
args: args{
|
||||
ctx: context.Background(),
|
||||
err: ErrEntryNotFound.WithParam("email", "jim@example.org").WithParam("other", "stuff"),
|
||||
},
|
||||
want: wrap(ErrEntryNotFound.WithParam("email", "jim@example.org").WithParam("other", "stuff"), Code("NOT_FOUND"), ErrorEntity("ENTRY"), Param("email", "jim@example.org"), Param("other", "stuff")),
|
||||
logged: []string{"level=ERROR msg=\"entry not found\""},
|
||||
},
|
||||
{
|
||||
name: "unknown code",
|
||||
args: args{
|
||||
ctx: context.Background(),
|
||||
err: presenter.NewCodedError("unknown code", "UNKNOWN", presenter.EntityEntry),
|
||||
},
|
||||
want: wrap(presenter.NewCodedError("unknown code", "UNKNOWN", presenter.EntityEntry), Code("INTERNAL"), ErrorEntity("ENTRY")),
|
||||
logged: []string{"level=ERROR msg=\"unknown code\""},
|
||||
},
|
||||
{
|
||||
name: "unknown entity",
|
||||
args: args{
|
||||
ctx: context.Background(),
|
||||
err: presenter.NewCodedError("unknown entity", presenter.CodeNotFound, "UNKNOWN"),
|
||||
},
|
||||
want: wrap(presenter.NewCodedError("unknown entity", presenter.CodeNotFound, "UNKNOWN"), Code("NOT_FOUND")),
|
||||
logged: []string{"level=ERROR msg=\"unknown entity\""},
|
||||
},
|
||||
{
|
||||
name: "unhandled error",
|
||||
args: args{
|
||||
ctx: context.Background(),
|
||||
err: fmt.Errorf("unhandled"),
|
||||
},
|
||||
want: wrap(fmt.Errorf("unhandled"), Code("INTERNAL")),
|
||||
logged: []string{"level=ERROR msg=unhandled"},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
logger := newMockLogger()
|
||||
got := presenter.New(logger.Logger(), AllErrorCode, AllTestErrorEntity, ErrorCodeInternal)(tt.args.ctx, tt.args.err)
|
||||
assert.Equal(t, tt.want, got)
|
||||
logger.Check(t, tt.logged)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type ErrorOpt = func(err *gqlerror.Error)
|
||||
|
||||
func Code(code ErrorCode) ErrorOpt {
|
||||
return func(err *gqlerror.Error) {
|
||||
err.Extensions["code"] = code
|
||||
}
|
||||
}
|
||||
|
||||
func ErrorEntity(entity TestErrorEntity) ErrorOpt {
|
||||
return func(err *gqlerror.Error) {
|
||||
err.Extensions["errorEntity"] = entity
|
||||
}
|
||||
}
|
||||
|
||||
func Param(key, value string) ErrorOpt {
|
||||
return func(err *gqlerror.Error) {
|
||||
if e, exists := err.Extensions["params"]; !exists {
|
||||
params := make(map[string]string)
|
||||
params[key] = value
|
||||
err.Extensions["params"] = params
|
||||
} else {
|
||||
e.(map[string]string)[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func wrap(err error, opts ...ErrorOpt) *gqlerror.Error {
|
||||
e := gqlerror.WrapPath(nil, err)
|
||||
e.Extensions = map[string]interface{}{}
|
||||
for _, o := range opts {
|
||||
o(e)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func newMockLogger() *mockLogger {
|
||||
logged := &bytes.Buffer{}
|
||||
|
||||
return &mockLogger{
|
||||
logged: logged,
|
||||
logger: slog.New(slog.NewTextHandler(logged, &slog.HandlerOptions{
|
||||
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
|
||||
if a.Key == "time" {
|
||||
return slog.Attr{}
|
||||
}
|
||||
return a
|
||||
},
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
type mockLogger struct {
|
||||
logger *slog.Logger
|
||||
logged *bytes.Buffer
|
||||
}
|
||||
|
||||
func (m *mockLogger) Logger() *slog.Logger {
|
||||
return m.logger
|
||||
}
|
||||
|
||||
func (m *mockLogger) Check(t testing.TB, wantLogged []string) {
|
||||
var gotLogged []string
|
||||
if m.logged.String() != "" {
|
||||
gotLogged = strings.Split(m.logged.String(), "\n")
|
||||
gotLogged = gotLogged[:len(gotLogged)-1]
|
||||
}
|
||||
if len(wantLogged) == 0 {
|
||||
assert.Empty(t, gotLogged)
|
||||
return
|
||||
}
|
||||
assert.Equal(t, wantLogged, gotLogged)
|
||||
}
|
||||
|
||||
type ErrorCode string
|
||||
|
||||
const (
|
||||
ErrorCodeNotFound ErrorCode = "NOT_FOUND"
|
||||
ErrorCodeConflict ErrorCode = "CONFLICT"
|
||||
ErrorCodePreconditionFailed ErrorCode = "PRECONDITION_FAILED"
|
||||
ErrorCodeInternal ErrorCode = "INTERNAL"
|
||||
)
|
||||
|
||||
var AllErrorCode = []ErrorCode{
|
||||
ErrorCodeNotFound,
|
||||
ErrorCodeConflict,
|
||||
ErrorCodePreconditionFailed,
|
||||
ErrorCodeInternal,
|
||||
}
|
||||
|
||||
type TestErrorEntity string
|
||||
|
||||
const (
|
||||
TestErrorEntityEntrySeries TestErrorEntity = "ENTRY_SERIES"
|
||||
TestErrorEntityFiscalYear TestErrorEntity = "FISCAL_YEAR"
|
||||
TestErrorEntityAccountClass TestErrorEntity = "ACCOUNT_CLASS"
|
||||
TestErrorEntityAccountGroup TestErrorEntity = "ACCOUNT_GROUP"
|
||||
TestErrorEntityAccount TestErrorEntity = "ACCOUNT"
|
||||
TestErrorEntityTag TestErrorEntity = "TAG"
|
||||
TestErrorEntityEntry TestErrorEntity = "ENTRY"
|
||||
TestErrorEntityEntryBasis TestErrorEntity = "ENTRY_BASIS"
|
||||
)
|
||||
|
||||
var AllTestErrorEntity = []TestErrorEntity{
|
||||
TestErrorEntityEntrySeries,
|
||||
TestErrorEntityFiscalYear,
|
||||
TestErrorEntityAccountClass,
|
||||
TestErrorEntityAccountGroup,
|
||||
TestErrorEntityAccount,
|
||||
TestErrorEntityTag,
|
||||
TestErrorEntityEntry,
|
||||
TestErrorEntityEntryBasis,
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:recommended"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user