Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 }}
6 changes: 5 additions & 1 deletion .golangci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
6 changes: 2 additions & 4 deletions apiendpoint/api_endpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
}

Expand Down Expand Up @@ -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()),
}
Expand Down
22 changes: 11 additions & 11 deletions apiendpoint/api_endpoint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)

Expand All @@ -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)
Expand All @@ -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.
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)

Expand All @@ -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)

Expand Down Expand Up @@ -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)

Expand Down
5 changes: 3 additions & 2 deletions apimiddleware/api_middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package apimiddleware

import (
"net/http"
"slices"
)

// middlewareInterface is an interface to be implemented by middleware.
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion apimiddleware/api_middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions apitype/explicit_nullable.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
16 changes: 6 additions & 10 deletions apitype/explicit_nullable_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
},
}

Expand Down Expand Up @@ -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",
Expand All @@ -174,7 +174,3 @@ func TestExplicitNullable_UnmarshalJSON(t *testing.T) {
})
}
}

func ptr[T any](v T) *T {
return &v
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -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
Expand Down
30 changes: 15 additions & 15 deletions internal/validate/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions internal/validate/validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
}