From fe253b56fa494c348b0e687162e1bb0d78b09817 Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Mon, 31 Aug 2026 20:10:36 -0500 Subject: [PATCH 1/2] upgrade to Go 1.26 and golangci-lint 2.13.1 The rivershared v0.45.0 update raises its module minimum to Go 1.26, above the version used to build the pinned golangci-lint binary. Lint therefore exits before analyzing code. Declare Go 1.26 as the library minimum and use stable Go with golangci-lint 2.13.1, matching the current River pattern. Upgrade the supporting GitHub Actions to their current major versions so they run on a supported Node runtime instead of GitHub's Node 20 fallback. --- .github/workflows/ci.yaml | 14 +++++++------- go.mod | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ada1fa9..340eaf5 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -12,10 +12,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v7 with: check-latest: true go-version-file: "go.mod" @@ -29,19 +29,19 @@ jobs: golangci-lint: runs-on: ubuntu-latest env: - GOLANGCI_LINT_VERSION: v2.4.0 + GOLANGCI_LINT_VERSION: v2.13.1 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v7 with: check-latest: true - go-version-file: "go.mod" + go-version: "stable" - name: Lint - uses: golangci/golangci-lint-action@v7 + uses: golangci/golangci-lint-action@v9 with: version: ${{ env.GOLANGCI_LINT_VERSION }} diff --git a/go.mod b/go.mod index 39a2ec8..0a32a8e 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/riverqueue/apiframe -go 1.25.0 +go 1.26.0 require ( github.com/go-playground/validator/v10 v10.30.3 From 3a53f5a1a4d2ba6f3873e9c28eca7b1acf311d5e Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Mon, 31 Aug 2026 20:10:41 -0500 Subject: [PATCH 2/2] modernize for Go 1.26 The newer lint suite introduces replacement linters for existing policy choices and enables additional checks through `default: all`. Preserve the intent of the exhaustive-struct policy, switch away from deprecated linter aliases, and avoid noisy repeated-literal and whitespace churn. Apply the useful Go modernization fixes emitted by the updated linter. Adopt typed error and reflection helpers, standard-library iterators and pointer expressions, efficient string construction, promoted embedded fields, and context-aware test requests. --- .golangci.yaml | 6 +++++- apiendpoint/api_endpoint.go | 6 ++---- apiendpoint/api_endpoint_test.go | 22 ++++++++++---------- apimiddleware/api_middleware.go | 5 +++-- apimiddleware/api_middleware_test.go | 2 +- apitype/explicit_nullable.go | 4 ++-- apitype/explicit_nullable_test.go | 16 ++++++--------- internal/validate/validate.go | 30 ++++++++++++++-------------- internal/validate/validate_test.go | 4 ++-- 9 files changed, 47 insertions(+), 48 deletions(-) diff --git a/.golangci.yaml b/.golangci.yaml index e5cef5d..9597d6c 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -8,16 +8,19 @@ linters: - wrapcheck # checks that errors are wrapped; currently not done anywhere # disabled because we're not compliant, but which we should think about - - exhaustruct # checks that properties in structs are exhaustively defined; may be a good idea + - exhaustruct # deprecated predecessor to exhaustruct_v5 + - exhaustruct_v5 # checks that properties in structs are exhaustively defined; may be a good idea - testpackage # requires tests in test packages like `river_test` # disabled because they're annoying/bad - cyclop # screams into the void at "cyclomatic complexity" - funlen # screams when functions are more than 60 lines long; what are we even doing here guys + - goconst # wants repeated test strings and other obvious literals to be constants; lots of churn. - interfacebloat # we do in fact want >10 methods on the Adapter interface or wherever we see fit. - gocognit # yells that "cognitive complexity" is too high; why - gocyclo # ANOTHER "cyclomatic complexity" checker (see also "cyclop" and "gocyclo") - godox # bans TODO statements; total non-starter at the moment + - gomodguard # deprecated alias replaced by gomodguard_v2 - err113 # wants all errors to be defined as variables at the package level; quite obnoxious - maintidx # ANOTHER ANOTHER "cyclomatic complexity" lint (see also "cyclop" and "gocyclo") - mnd # detects "magic numbers", which it defines as any number; annoying @@ -27,6 +30,7 @@ linters: - nlreturn # requires a blank line before returns; annoying - noinlineerr # disallows `if err := ...`; because why miss an opportunity to leak variables out of scope? - wsl # a bunch of style/whitespace stuff; annoying + - wsl_v5 # a second version of the first annoying wsl; how nice settings: depguard: diff --git a/apiendpoint/api_endpoint.go b/apiendpoint/api_endpoint.go index e069d27..2824079 100644 --- a/apiendpoint/api_endpoint.go +++ b/apiendpoint/api_endpoint.go @@ -147,8 +147,7 @@ func executeAPIEndpoint[TReq any, TResp any](w http.ResponseWriter, r *http.Requ if r.Method != http.MethodGet { reqData, err := io.ReadAll(r.Body) if err != nil { - var maxBytesErr *http.MaxBytesError - if errors.As(err, &maxBytesErr) { + if _, ok := errors.AsType[*http.MaxBytesError](err); ok { return apierror.NewRequestEntityTooLarge("Request entity too large.") } @@ -202,8 +201,7 @@ func executeAPIEndpoint[TReq any, TResp any](w http.ResponseWriter, r *http.Requ // user-friendly than an internal server error. err = maybeInterpretInternalError(err) - var apiErr apierror.Interface - if errors.As(err, &apiErr) { + if apiErr, ok := errors.AsType[apierror.Interface](err); ok { logAttrs := []any{ slog.String("error", apiErr.Error()), } diff --git a/apiendpoint/api_endpoint_test.go b/apiendpoint/api_endpoint_test.go index dc0a778..1bc66e2 100644 --- a/apiendpoint/api_endpoint_test.go +++ b/apiendpoint/api_endpoint_test.go @@ -54,7 +54,7 @@ func TestMountAndServe(t *testing.T) { mux, bundle := setup(t) - req := httptest.NewRequest(http.MethodGet, "/api/get-endpoint", nil) + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/get-endpoint", nil) mux.ServeHTTP(bundle.recorder, req) requireStatusAndJSONResponse(t, http.StatusOK, &getResponse{Message: "Hello."}, bundle.recorder) @@ -65,7 +65,7 @@ func TestMountAndServe(t *testing.T) { mux, bundle := setup(t) - req := httptest.NewRequest(http.MethodGet, "/api/get-endpoint", + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/get-endpoint", bytes.NewBuffer(mustMarshalJSON(t, &getRequest{IgnoredJSONMessage: "Ignored hello."}))) mux.ServeHTTP(bundle.recorder, req) @@ -79,7 +79,7 @@ func TestMountAndServe(t *testing.T) { payload := mustMarshalJSON(t, &postRequest{Message: "Hello."}) - req := httptest.NewRequest(http.MethodPost, "/api/post-endpoint/123", bytes.NewBuffer(payload)) + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/post-endpoint/123", bytes.NewBuffer(payload)) req.Body = http.MaxBytesReader(bundle.recorder, io.NopCloser(bytes.NewReader(payload)), int64(len(payload)-1)) mux.ServeHTTP(bundle.recorder, req) requireStatusAndJSONResponse(t, http.StatusRequestEntityTooLarge, &apierror.APIError{Message: "Request entity too large."}, bundle.recorder) @@ -90,7 +90,7 @@ func TestMountAndServe(t *testing.T) { mux, bundle := setup(t) - req := httptest.NewRequest(http.MethodPost, "/api/get-endpoint", nil) + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/get-endpoint", nil) mux.ServeHTTP(bundle.recorder, req) // This error comes from net/http. @@ -106,7 +106,7 @@ func TestMountAndServe(t *testing.T) { Mount(mux, &postEndpoint{}, nil) reqPayload := mustMarshalJSON(t, &postRequest{Message: "Hello."}) - req := httptest.NewRequest(http.MethodPost, "/api/post-endpoint/123", bytes.NewBuffer(reqPayload)) + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/post-endpoint/123", bytes.NewBuffer(reqPayload)) mux.ServeHTTP(bundle.recorder, req) requireStatusAndJSONResponse(t, http.StatusCreated, &postResponse{ID: "123", Message: "Hello.", RawPayload: reqPayload}, bundle.recorder) @@ -120,7 +120,7 @@ func TestMountAndServe(t *testing.T) { mux := http.NewServeMux() Mount(mux, &getEndpoint{}, &MountOpts{Logger: bundle.logger}) - req := httptest.NewRequest(http.MethodGet, "/api/get-endpoint", nil) + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/get-endpoint", nil) mux.ServeHTTP(bundle.recorder, req) requireStatusAndJSONResponse(t, http.StatusOK, &getResponse{Message: "Hello."}, bundle.recorder) @@ -132,7 +132,7 @@ func TestMountAndServe(t *testing.T) { mux, bundle := setup(t) reqPayload := mustMarshalJSON(t, &postRequest{Message: "Hello."}) - req := httptest.NewRequest(http.MethodPost, "/api/post-endpoint/123", bytes.NewBuffer(reqPayload)) + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/post-endpoint/123", bytes.NewBuffer(reqPayload)) mux.ServeHTTP(bundle.recorder, req) requireStatusAndJSONResponse(t, http.StatusCreated, &postResponse{ID: "123", Message: "Hello.", RawPayload: reqPayload}, bundle.recorder) @@ -143,7 +143,7 @@ func TestMountAndServe(t *testing.T) { mux, bundle := setup(t) - req := httptest.NewRequest(http.MethodPost, "/api/post-endpoint/123", nil) + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/post-endpoint/123", nil) mux.ServeHTTP(bundle.recorder, req) requireStatusAndJSONResponse(t, http.StatusBadRequest, &apierror.APIError{Message: "Field `message` is required."}, bundle.recorder) @@ -154,7 +154,7 @@ func TestMountAndServe(t *testing.T) { mux, bundle := setup(t) - req := httptest.NewRequest(http.MethodPost, "/api/post-endpoint/123", + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/post-endpoint/123", bytes.NewBuffer(mustMarshalJSON(t, &postRequest{MakeAPIError: true, Message: "Hello."}))) mux.ServeHTTP(bundle.recorder, req) @@ -166,7 +166,7 @@ func TestMountAndServe(t *testing.T) { mux, bundle := setup(t) - req := httptest.NewRequest(http.MethodPost, "/api/post-endpoint/123", + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/post-endpoint/123", bytes.NewBuffer(mustMarshalJSON(t, &postRequest{MakePostgresError: true, Message: "Hello."}))) mux.ServeHTTP(bundle.recorder, req) @@ -194,7 +194,7 @@ func TestMountAndServe(t *testing.T) { mux, bundle := setup(t) - req := httptest.NewRequest(http.MethodPost, "/api/post-endpoint/123", + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/post-endpoint/123", bytes.NewBuffer(mustMarshalJSON(t, &postRequest{MakeInternalError: true, Message: "Hello."}))) mux.ServeHTTP(bundle.recorder, req) diff --git a/apimiddleware/api_middleware.go b/apimiddleware/api_middleware.go index fd70844..01c8111 100644 --- a/apimiddleware/api_middleware.go +++ b/apimiddleware/api_middleware.go @@ -2,6 +2,7 @@ package apimiddleware import ( "net/http" + "slices" ) // middlewareInterface is an interface to be implemented by middleware. @@ -61,8 +62,8 @@ func NewMiddlewareStack(middlewares ...middlewareInterface) *MiddlewareStack { } func (s *MiddlewareStack) Mount(handler http.Handler) http.Handler { - for i := len(s.middlewares) - 1; i >= 0; i-- { - handler = s.middlewares[i].Middleware(handler) + for _, v := range slices.Backward(s.middlewares) { + handler = v.Middleware(handler) } return handler diff --git a/apimiddleware/api_middleware_test.go b/apimiddleware/api_middleware_test.go index adc48f8..ceb8f3a 100644 --- a/apimiddleware/api_middleware_test.go +++ b/apimiddleware/api_middleware_test.go @@ -52,7 +52,7 @@ func TestMiddlewareStack(t *testing.T) { })) recorder := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "https://example.com", nil) + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "https://example.com", nil) handler.ServeHTTP(recorder, req) return contextTrail diff --git a/apitype/explicit_nullable.go b/apitype/explicit_nullable.go index 39f6425..f09bb79 100644 --- a/apitype/explicit_nullable.go +++ b/apitype/explicit_nullable.go @@ -37,8 +37,8 @@ func (ps *ExplicitNullable[T]) UnmarshalJSON(data []byte) error { // empty strings. // // This function is designed to be used with validator.RegisterCustomTypeFunc. -func ExtractExplicitNullableValueForValidation[T any](field reflect.Value) interface{} { - ps, ok := field.Interface().(ExplicitNullable[T]) +func ExtractExplicitNullableValueForValidation[T any](field reflect.Value) any { + ps, ok := reflect.TypeAssert[ExplicitNullable[T]](field) if !ok || !ps.Set || ps.Value == nil { return nil } diff --git a/apitype/explicit_nullable_test.go b/apitype/explicit_nullable_test.go index 1bd1985..fdca9b5 100644 --- a/apitype/explicit_nullable_test.go +++ b/apitype/explicit_nullable_test.go @@ -90,13 +90,13 @@ func TestExtractExplicitNullableValueForValidation(t *testing.T) { }, { name: "EmptyStringValue", - input: ExplicitNullable[string]{Set: true, Value: ptr("")}, - wantVal: ptr(""), + input: ExplicitNullable[string]{Set: true, Value: new("")}, + wantVal: new(""), }, { name: "NonEmptyStringValue", - input: ExplicitNullable[string]{Set: true, Value: ptr("test")}, - wantVal: ptr("test"), + input: ExplicitNullable[string]{Set: true, Value: new("test")}, + wantVal: new("test"), }, } @@ -142,12 +142,12 @@ func TestExplicitNullable_UnmarshalJSON(t *testing.T) { { name: "EmptyString", json: `{"label":""}`, - want: ExplicitNullable[string]{Set: true, Value: ptr("")}, + want: ExplicitNullable[string]{Set: true, Value: new("")}, }, { name: "NonEmptyString", json: `{"label":"test"}`, - want: ExplicitNullable[string]{Set: true, Value: ptr("test")}, + want: ExplicitNullable[string]{Set: true, Value: new("test")}, }, { name: "InvalidJSON", @@ -174,7 +174,3 @@ func TestExplicitNullable_UnmarshalJSON(t *testing.T) { }) } } - -func ptr[T any](v T) *T { - return &v -} diff --git a/internal/validate/validate.go b/internal/validate/validate.go index ab44821..fc848f2 100644 --- a/internal/validate/validate.go +++ b/internal/validate/validate.go @@ -25,7 +25,7 @@ func init() { //nolint:gochecknoinits // I only added a few possible validations to start. We'll probably need to add // more as we go and expand our usage. func PublicFacingMessage(v *validator.Validate, validatorErr error) string { - var message string + var message strings.Builder //nolint:errorlint if validationErrs, ok := validatorErr.(validator.ValidationErrors); ok { @@ -35,66 +35,66 @@ func PublicFacingMessage(v *validator.Validate, validatorErr error) string { fallthrough // lte and max are synonyms case "max": kind := fieldErr.Kind() - if kind == reflect.Ptr { + if kind == reflect.Pointer { kind = fieldErr.Type().Elem().Kind() } switch kind { //nolint:exhaustive case reflect.Float32, reflect.Float64, reflect.Int, reflect.Int32, reflect.Int64: - message += fmt.Sprintf(" Field `%s` must be less than or equal to %s.", + fmt.Fprintf(&message, " Field `%s` must be less than or equal to %s.", fieldErr.Field(), fieldErr.Param()) case reflect.Slice, reflect.Map: - message += fmt.Sprintf(" Field `%s` must contain at most %s element(s).", + fmt.Fprintf(&message, " Field `%s` must contain at most %s element(s).", fieldErr.Field(), fieldErr.Param()) case reflect.String: - message += fmt.Sprintf(" Field `%s` must be at most %s character(s) long.", + fmt.Fprintf(&message, " Field `%s` must be at most %s character(s) long.", fieldErr.Field(), fieldErr.Param()) default: - message += fieldErr.Error() + message.WriteString(fieldErr.Error()) } case "gte": fallthrough // gte and min are synonyms case "min": kind := fieldErr.Kind() - if kind == reflect.Ptr { + if kind == reflect.Pointer { kind = fieldErr.Type().Elem().Kind() } switch kind { //nolint:exhaustive case reflect.Float32, reflect.Float64, reflect.Int, reflect.Int32, reflect.Int64: - message += fmt.Sprintf(" Field `%s` must be greater or equal to %s.", + fmt.Fprintf(&message, " Field `%s` must be greater or equal to %s.", fieldErr.Field(), fieldErr.Param()) case reflect.Slice, reflect.Map: - message += fmt.Sprintf(" Field `%s` must contain at least %s element(s).", + fmt.Fprintf(&message, " Field `%s` must contain at least %s element(s).", fieldErr.Field(), fieldErr.Param()) case reflect.String: - message += fmt.Sprintf(" Field `%s` must be at least %s character(s) long.", + fmt.Fprintf(&message, " Field `%s` must be at least %s character(s) long.", fieldErr.Field(), fieldErr.Param()) default: - message += fieldErr.Error() + message.WriteString(fieldErr.Error()) } case "oneof": - message += fmt.Sprintf(" Field `%s` should be one of the following values: %s.", + fmt.Fprintf(&message, " Field `%s` should be one of the following values: %s.", fieldErr.Field(), fieldErr.Param()) case "required": - message += fmt.Sprintf(" Field `%s` is required.", fieldErr.Field()) + fmt.Fprintf(&message, " Field `%s` is required.", fieldErr.Field()) default: - message += fmt.Sprintf(" Validation on field `%s` failed on the `%s` tag.", fieldErr.Field(), fieldErr.Tag()) + fmt.Fprintf(&message, " Validation on field `%s` failed on the `%s` tag.", fieldErr.Field(), fieldErr.Tag()) } } } - return strings.TrimSpace(message) + return strings.TrimSpace(message.String()) } // preferPublicName is a validator tag naming function that uses public names diff --git a/internal/validate/validate_test.go b/internal/validate/validate_test.go index 6d13d0d..3a0c046 100644 --- a/internal/validate/validate_test.go +++ b/internal/validate/validate_test.go @@ -133,7 +133,7 @@ func TestPreferPublicNames(t *testing.T) { } require.Equal(t, "json_name", - preferPublicName(reflect.TypeOf(testStruct{}).Field(0))) + preferPublicName(reflect.TypeFor[testStruct]().Field(0))) require.Equal(t, "StructNameField", - preferPublicName(reflect.TypeOf(testStruct{}).Field(1))) + preferPublicName(reflect.TypeFor[testStruct]().Field(1))) }