swaggest / rest

Web services with OpenAPI and JSON Schema done quick in Go
https://pkg.go.dev/github.com/swaggest/rest
MIT License
335 stars 17 forks source link

Cannot validate incorrect types #202

Open threesquared opened 2 months ago

threesquared commented 2 months ago

Describe the bug

When validating an input with an incorrect type we get an failed to decode json: json: cannot unmarshall... error instead of a validation failed error with a context object pointing to the issue. This is because the un-marshalling fails before the validator is even run.

To Reproduce

package main

import (
    "bytes"
    "context"
    "net/http"
    "net/http/httptest"
    "testing"

    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
    "github.com/swaggest/rest/web"
    "github.com/swaggest/usecase"
)

func TestFoo(t *testing.T) {
    type TestInput struct {
        TestString string `json:"testString" default:"valid" required:"true" minLength:"5" maxLength:"10" pattern:"^[a-z]+$"`
    }

    s := web.DefaultService()

    s.Post("/foo", usecase.NewInteractor(func(ctx context.Context, input TestInput, output *string) error {
        *output = input.TestString

        return nil
    }))

    req, err := http.NewRequest(http.MethodPost, "/foo", bytes.NewReader([]byte(`{"testString":77}`)))
    require.NoError(t, err)
    req.Header.Set("Content-Type", "application/json")
    rw := httptest.NewRecorder()

    s.ServeHTTP(rw, req)
    assert.Contains(t, rw.Body.String(), "validation failed")
    assert.Equal(t, http.StatusBadRequest, rw.Code)
}

Expected behavior

I would expect a type mismatch issue to be reported in the same way as any other validation error to the end user

Leskodamus commented 2 days ago

First, instead of using a io.TeeReader, just read the request body into the buffer, then perform data validation and only then, if no error occurs, unmarshal the JSON bytes into the input object.

Something like this:

decodeJSONBody(readJSON func(rd io.Reader, v interface{}) error, tolerateFormData bool) valueDecoderFunc {
  // ...

  var b *bytes.Buffer

  b = bufPool.Get().(*bytes.Buffer) //nolint:errcheck // bufPool is configured to provide *bytes.Buffer.
  defer bufPool.Put(b)
  b.Reset()

  // First read body into buffer.
  if _, err := b.ReadFrom(r.Body); err != nil {
    return err
  }

  validate := validator != nil && validator.HasConstraints(rest.ParamInBody)

  if validate {
    // Perform validation before unmarshalling into input object.
    err := validator.ValidateJSONBody(b.Bytes())
    if err != nil {
      return err
    }
  }

  return readJSON(b, input)
}

Then you can get the desired result, for example:

{
  "status": "INVALID_ARGUMENT",
  "error": "invalid argument: validation failed",
  "context": {
    "body": [
      "#/name: expected string, but got number"
    ]
  }
}

Edit: messed up on my end :) This change seems to make it work.