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