feat: initial version

This commit is contained in:
2025-03-30 13:27:34 +02:00
commit d354d7b831
14 changed files with 628 additions and 0 deletions
+11
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
.idea
/release
+36
View File
@@ -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 ./...
+22
View File
@@ -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$
+46
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
# Shiny global error presenter
GraphQL global error presenter handling coded errors.
[![Build Status](https://gitlab.com/unboundsoftware/shiny/presenter/badges/main/pipeline.svg)](https://gitlab.com/unboundsoftware/shiny/presenter/commits/main)
[![codecov](https://codecov.io/gl/unboundsoftware:shiny/presenter/branch/main/graph/badge.svg?token=8F3HKACF7A)](https://codecov.io/gl/unboundsoftware:shiny/presenter)
+87
View File
@@ -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]+"
+96
View File
@@ -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{}
+24
View File
@@ -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)
)
+17
View File
@@ -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
)
+22
View File
@@ -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=
+58
View File
@@ -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 ""
}
+195
View File
@@ -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,
}
+6
View File
@@ -0,0 +1,6 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended"
]
}