package decoderx
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"sync"
"testing"
"github.com/tidwall/gjson"
"github.com/pkg/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/jsonschema/v3"
)
func newRequest(t *testing.T, method, url string, body io.Reader, ct string) *http.Request {
req := httptest.NewRequest(method, url, body)
req.Header.Set("Content-Type", ct)
return req
}
func TestHTTPFormDecoder(t *testing.T) {
for k, tc := range []struct {
d string
request *http.Request
contentType string
options []HTTPDecoderOption
expected string
expectedError string
}{
{
d: "should fail because the method is GET",
request: &http.Request{Header: map[string][]string{}, Method: "GET"},
expectedError: "HTTP Request Method",
},
{
d: "should fail because the body is empty",
request: &http.Request{Header: map[string][]string{}, Method: "POST"},
expectedError: "Content-Length",
},
{
d: "should fail because content type is missing",
request: newRequest(t, "POST", "/", nil, ""),
expectedError: "Content-Length",
},
{
d: "should fail because content type is missing",
request: newRequest(t, "POST", "/", bytes.NewBufferString("foo"), ""),
expectedError: "Content-Type",
},
{
d: "should pass with json without validation",
request: newRequest(t, "POST", "/", bytes.NewBufferString(`{"foo":"bar"}`), httpContentTypeJSON),
expected: `{"foo":"bar"}`,
},
{
d: "should fail json if content type is not accepted",
request: newRequest(t, "POST", "/", bytes.NewBufferString(`{"foo":"bar"}`), httpContentTypeJSON),
options: []HTTPDecoderOption{HTTPFormDecoder()},
expectedError: "Content-Type: application/json",
},
{
d: "should fail json if validation fails",
request: newRequest(t, "POST", "/", bytes.NewBufferString(`{"foo":"bar", "bar":"baz"}`), httpContentTypeJSON),
options: []HTTPDecoderOption{HTTPJSONDecoder(), MustHTTPRawJSONSchemaCompiler([]byte(`{
"$id": "https://example.com/config.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"foo": {
"type": "number"
},
"bar": {
"type": "string"
}
}
}`),
)},
expectedError: "expected number, but got string",
expected: `{ "bar": "baz", "foo": "bar" }`,
},
{
d: "should pass json with validation",
request: newRequest(t, "POST", "/", bytes.NewBufferString(`{"foo":"bar"}`), httpContentTypeJSON),
options: []HTTPDecoderOption{HTTPJSONDecoder(), MustHTTPRawJSONSchemaCompiler([]byte(`{
"$id": "https://example.com/config.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"foo": {
"type": "string"
}
}
}`),
),
},
expected: `{"foo":"bar"}`,
},
{
d: "should fail form request when form is used but only json is allowed",
request: newRequest(t, "POST", "/", bytes.NewBufferString(url.Values{"foo": {"bar"}}.Encode()), httpContentTypeURLEncodedForm),
options: []HTTPDecoderOption{HTTPJSONDecoder()},
expectedError: "Content-Type: application/x-www-form-urlencoded",
},
{
d: "should fail form request when schema is missing",
request: newRequest(t, "POST", "/", bytes.NewBufferString(url.Values{"foo": {"bar"}}.Encode()), httpContentTypeURLEncodedForm),
options: []HTTPDecoderOption{},
expectedError: "no validation schema was provided",
},
{
d: "should fail form request when schema does not validate request",
request: newRequest(t, "POST", "/", bytes.NewBufferString(url.Values{"bar": {"bar"}}.Encode()), httpContentTypeURLEncodedForm),
options: []HTTPDecoderOption{HTTPJSONSchemaCompiler("stub/schema.json", nil)},
expectedError: `missing properties: "foo"`,
},
{
d: "should pass form request and type assert data",
request: newRequest(t, "POST", "/", bytes.NewBufferString(url.Values{
"name.first": {"Aeneas"},
"name.last": {"Rekkas"},
"age": {"29"},
"ratio": {"0.9"},
"consent": {"true"},
// newsletter represents a special case for checkbox input with true/false and raw HTML.
"newsletter": {
"false", // comes from
"true", // comes from
},
}.Encode()), httpContentTypeURLEncodedForm),
options: []HTTPDecoderOption{HTTPJSONSchemaCompiler("stub/person.json", nil)},
expected: `{
"name": {"first": "Aeneas", "last": "Rekkas"},
"age": 29,
"newsletter": true,
"consent": true,
"ratio": 0.9
}`,
},
{
d: "should pass form request with payload in query and type assert data",
request: newRequest(t, "POST", "/?age=29", bytes.NewBufferString(url.Values{
"name.first": {"Aeneas"},
"name.last": {"Rekkas"},
"ratio": {"0.9"},
"consent": {"true"},
// newsletter represents a special case for checkbox input with true/false and raw HTML.
"newsletter": {
"false", // comes from
"true", // comes from
},
}.Encode()), httpContentTypeURLEncodedForm),
options: []HTTPDecoderOption{HTTPJSONSchemaCompiler("stub/person.json", nil)},
expected: `{
"name": {"first": "Aeneas", "last": "Rekkas"},
"newsletter": true,
"consent": true,
"ratio": 0.9
}`,
},
{
d: "should pass form request with payload in query and type assert data",
request: newRequest(t, "POST", "/?age=29", bytes.NewBufferString(url.Values{
"name.first": {"Aeneas"},
"name.last": {"Rekkas"},
"ratio": {"0.9"},
"consent": {"true"},
// newsletter represents a special case for checkbox input with true/false and raw HTML.
"newsletter": {
"false", // comes from
"true", // comes from
},
}.Encode()), httpContentTypeURLEncodedForm),
options: []HTTPDecoderOption{
HTTPDecoderUseQueryAndBody(),
HTTPJSONSchemaCompiler("stub/person.json", nil),
},
expected: `{
"name": {"first": "Aeneas", "last": "Rekkas"},
"age": 29,
"newsletter": true,
"consent": true,
"ratio": 0.9
}`,
},
{
d: "should fail json request formatted as form if payload is invalid",
request: newRequest(t, "POST", "/", bytes.NewBufferString(`{"name.first":"Aeneas", "name.last":"Rekkas","age":"not-a-number"}`), httpContentTypeJSON),
options: []HTTPDecoderOption{HTTPJSONSchemaCompiler("stub/person.json", nil)},
expectedError: "expected integer, but got string",
},
{
d: "should pass JSON request formatted as a form",
request: newRequest(t, "POST", "/", bytes.NewBufferString(`{
"name.first": "Aeneas",
"name.last": "Rekkas",
"age": 29,
"ratio": 0.9,
"consent": false,
"newsletter": true
}`), httpContentTypeJSON),
options: []HTTPDecoderOption{HTTPDecoderJSONFollowsFormFormat(),
HTTPJSONSchemaCompiler("stub/person.json", nil)},
expected: `{
"name": {"first": "Aeneas", "last": "Rekkas"},
"age": 29,
"newsletter": true,
"consent": false,
"ratio": 0.9
}`,
},
{
d: "should pass JSON request formatted as a form",
request: newRequest(t, "POST", "/?age=29", bytes.NewBufferString(`{
"name.first": "Aeneas",
"name.last": "Rekkas",
"ratio": 0.9,
"consent": false,
"newsletter": true
}`), httpContentTypeJSON),
options: []HTTPDecoderOption{HTTPDecoderJSONFollowsFormFormat(),
HTTPJSONSchemaCompiler("stub/person.json", nil)},
expected: `{
"name": {"first": "Aeneas", "last": "Rekkas"},
"newsletter": true,
"consent": false,
"ratio": 0.9
}`,
},
{
d: "should pass JSON request formatted as a form",
request: newRequest(t, "POST", "/?age=29", bytes.NewBufferString(`{
"name.first": "Aeneas",
"name.last": "Rekkas",
"ratio": 0.9,
"consent": false,
"newsletter": true
}`), httpContentTypeJSON),
options: []HTTPDecoderOption{
HTTPDecoderUseQueryAndBody(),
HTTPDecoderJSONFollowsFormFormat(),
HTTPJSONSchemaCompiler("stub/person.json", nil)},
expected: `{
"name": {"first": "Aeneas", "last": "Rekkas"},
"age": 29,
"newsletter": true,
"consent": false,
"ratio": 0.9
}`,
},
{
d: "should pass JSON request GET request",
request: newRequest(t, "GET", "/?"+url.Values{
"name.first": {"Aeneas"},
"name.last": {"Rekkas"},
"age": {"29"},
"ratio": {"0.9"},
"consent": {"false"},
"newsletter": {"true"},
}.Encode(), nil, ""),
options: []HTTPDecoderOption{
HTTPJSONSchemaCompiler("stub/person.json", nil),
HTTPDecoderAllowedMethods("GET"),
},
expected: `{
"name": {"first": "Aeneas", "last": "Rekkas"},
"age": 29,
"newsletter": true,
"consent": false,
"ratio": 0.9
}`,
},
{
d: "should fail because json is not an object when using form format",
request: newRequest(t, "POST", "/", bytes.NewBufferString(`[]`), httpContentTypeJSON),
options: []HTTPDecoderOption{HTTPDecoderJSONFollowsFormFormat(),
HTTPJSONSchemaCompiler("stub/person.json", nil)},
expectedError: "be an object",
},
{
d: "should work with ParseErrorIgnoreConversionErrors",
request: newRequest(t, "POST", "/", bytes.NewBufferString(url.Values{
"ratio": {"foobar"},
}.Encode()), httpContentTypeURLEncodedForm),
options: []HTTPDecoderOption{
HTTPJSONSchemaCompiler("stub/person.json", nil),
HTTPDecoderSetIgnoreParseErrorsStrategy(ParseErrorIgnoreConversionErrors),
HTTPDecoderSetValidatePayloads(false),
},
expected: `{"ratio": "foobar"}`,
},
{
d: "should work with ParseErrorIgnoreConversionErrors",
request: newRequest(t, "POST", "/", bytes.NewBufferString(url.Values{
"ratio": {"foobar"},
}.Encode()), httpContentTypeURLEncodedForm),
options: []HTTPDecoderOption{HTTPJSONSchemaCompiler("stub/person.json", nil), HTTPDecoderSetIgnoreParseErrorsStrategy(ParseErrorUseEmptyValueOnConversionErrors)},
expected: `{"ratio": 0.0}`,
},
{
d: "should work with ParseErrorIgnoreConversionErrors",
request: newRequest(t, "POST", "/", bytes.NewBufferString(url.Values{
"ratio": {"foobar"},
}.Encode()), httpContentTypeURLEncodedForm),
options: []HTTPDecoderOption{HTTPJSONSchemaCompiler("stub/person.json", nil), HTTPDecoderSetIgnoreParseErrorsStrategy(ParseErrorReturnOnConversionErrors)},
expectedError: `strconv.ParseFloat: parsing "foobar"`,
},
{
d: "should interpret numbers as string if mandated by the schema",
request: newRequest(t, "POST", "/", bytes.NewBufferString(url.Values{
"name.first": {"12345"},
}.Encode()), httpContentTypeURLEncodedForm),
options: []HTTPDecoderOption{HTTPJSONSchemaCompiler("stub/person.json", nil), HTTPDecoderSetIgnoreParseErrorsStrategy(ParseErrorUseEmptyValueOnConversionErrors)},
expected: `{"name": {"first": "12345"}}`,
},
} {
t.Run(fmt.Sprintf("case=%d/description=%s", k, tc.d), func(t *testing.T) {
dec := NewHTTP()
var destination json.RawMessage
err := dec.Decode(tc.request, &destination, tc.options...)
if tc.expectedError != "" {
if e, ok := errors.Cause(err).(*jsonschema.ValidationError); ok {
t.Logf("%+v", e)
}
require.Error(t, err)
require.Contains(t, fmt.Sprintf("%+v", err), tc.expectedError)
if len(tc.expected) > 0 {
assert.JSONEq(t, tc.expected, string(destination))
}
return
}
require.NoError(t, err)
assert.JSONEq(t, tc.expected, string(destination))
})
}
t.Run("description=read body twice", func(t *testing.T) {
var wg sync.WaitGroup
wg.Add(1)
dec := NewHTTP()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer wg.Done()
var destination json.RawMessage
require.NoError(t, dec.Decode(r, &destination, HTTPJSONSchemaCompiler("stub/person.json", nil), HTTPKeepRequestBody(true)))
assert.EqualValues(t, "12345", gjson.GetBytes(destination, "name.first").String())
require.NoError(t, dec.Decode(r, &destination, HTTPJSONSchemaCompiler("stub/person.json", nil), HTTPKeepRequestBody(true)))
assert.EqualValues(t, "12345", gjson.GetBytes(destination, "name.first").String())
}))
t.Cleanup(ts.Close)
_, err := ts.Client().PostForm(ts.URL, url.Values{"name.first": {"12345"}})
require.NoError(t, err)
wg.Wait()
})
}