diff --git a/connector/internal/contenttype/multipart.go b/connector/internal/contenttype/multipart.go index c5a7215..71b4e48 100644 --- a/connector/internal/contenttype/multipart.go +++ b/connector/internal/contenttype/multipart.go @@ -1,100 +1,278 @@ package contenttype import ( - "encoding/json" + "bytes" "fmt" - "io" - "mime/multipart" "net/http" - "net/textproto" + "reflect" + "slices" - "github.com/hasura/ndc-http/ndc-http-schema/schema" + rest "github.com/hasura/ndc-http/ndc-http-schema/schema" + "github.com/hasura/ndc-sdk-go/schema" "github.com/hasura/ndc-sdk-go/utils" ) -// MultipartWriter extends multipart.Writer with helpers -type MultipartWriter struct { - *multipart.Writer +// MultipartFormEncoder implements a multipart/form encoder. +type MultipartFormEncoder struct { + schema *rest.NDCHttpSchema + paramEncoder *URLParameterEncoder + operation *rest.OperationInfo + arguments map[string]any } -// NewMultipartWriter creates a MultipartWriter instance -func NewMultipartWriter(w io.Writer) *MultipartWriter { - return &MultipartWriter{multipart.NewWriter(w)} +func NewMultipartFormEncoder(schema *rest.NDCHttpSchema, operation *rest.OperationInfo, arguments map[string]any) *MultipartFormEncoder { + return &MultipartFormEncoder{ + schema: schema, + paramEncoder: NewURLParameterEncoder(schema), + operation: operation, + arguments: arguments, + } } -// WriteDataURI write a file from data URI string -func (w *MultipartWriter) WriteDataURI(name string, value any, headers http.Header) error { - b64, err := utils.DecodeString(value) - if err != nil { - return fmt.Errorf("%s: %w", name, err) - } - dataURI, err := DecodeDataURI(b64) - if err != nil { - return fmt.Errorf("%s: %w", name, err) +// Encode the multipart form. +func (c *MultipartFormEncoder) Encode(bodyData any) (*bytes.Reader, string, error) { + bodyInfo, ok := c.operation.Arguments[rest.BodyKey] + if !ok { + return nil, "", errRequestBodyTypeRequired } - h := make(textproto.MIMEHeader) - for key, header := range headers { - h[key] = header - } - h.Set("Content-Disposition", - fmt.Sprintf(`form-data; name="%s"; filename="%s"`, - escapeQuotes(name), escapeQuotes(name))) - - if dataURI.MediaType == "" { - h.Set("Content-Type", schema.ContentTypeOctetStream) - } else { - h.Set("Content-Type", dataURI.MediaType) - } + buffer := new(bytes.Buffer) + writer := NewMultipartWriter(buffer) - p, err := w.CreatePart(h) - if err != nil { - return fmt.Errorf("%s: %w", name, err) + if err := c.evalMultipartForm(writer, &bodyInfo, reflect.ValueOf(bodyData)); err != nil { + return nil, "", err + } + if err := writer.Close(); err != nil { + return nil, "", err } - _, err = p.Write([]byte(dataURI.Data)) + reader := bytes.NewReader(buffer.Bytes()) + buffer.Reset() - return err + return reader, writer.FormDataContentType(), nil } -// WriteField calls CreateFormField and then writes the given value with json encoding. -func (w *MultipartWriter) WriteJSON(fieldName string, value any, headers http.Header) error { - bs, err := json.Marshal(value) - if err != nil { - return err +func (mfb *MultipartFormEncoder) evalMultipartForm(w *MultipartWriter, bodyInfo *rest.ArgumentInfo, bodyData reflect.Value) error { + bodyData, ok := utils.UnwrapPointerFromReflectValue(bodyData) + if !ok { + return nil } + switch bodyType := bodyInfo.Type.Interface().(type) { + case *schema.NullableType: + return mfb.evalMultipartForm(w, &rest.ArgumentInfo{ + ArgumentInfo: schema.ArgumentInfo{ + Type: bodyType.UnderlyingType, + }, + HTTP: bodyInfo.HTTP, + }, bodyData) + case *schema.NamedType: + if !ok { + return fmt.Errorf("%s: %w", rest.BodyKey, errArgumentRequired) + } + bodyObject, ok := mfb.schema.ObjectTypes[bodyType.Name] + if !ok { + break + } + kind := bodyData.Kind() + switch kind { + case reflect.Map, reflect.Interface: + bi := bodyData.Interface() + bodyMap, ok := bi.(map[string]any) + if !ok { + return fmt.Errorf("invalid multipart form body, expected object, got %v", bi) + } - h := createFieldMIMEHeader(fieldName, headers) - h.Set(schema.ContentTypeHeader, schema.ContentTypeJSON) - p, err := w.CreatePart(h) - if err != nil { - return err - } + for key, fieldInfo := range bodyObject.Fields { + fieldValue := bodyMap[key] + var enc *rest.EncodingObject + if len(mfb.operation.Request.RequestBody.Encoding) > 0 { + en, ok := mfb.operation.Request.RequestBody.Encoding[key] + if ok { + enc = &en + } + } - _, err = p.Write(bs) + if err := mfb.evalMultipartFieldValueRecursive(w, key, reflect.ValueOf(fieldValue), &fieldInfo, enc); err != nil { + return err + } + } - return err -} + return nil + case reflect.Struct: + reflectType := bodyData.Type() + for fieldIndex := range bodyData.NumField() { + fieldValue := bodyData.Field(fieldIndex) + fieldType := reflectType.Field(fieldIndex) + fieldInfo, ok := bodyObject.Fields[fieldType.Name] + if !ok { + continue + } + + var enc *rest.EncodingObject + if len(mfb.operation.Request.RequestBody.Encoding) > 0 { + en, ok := mfb.operation.Request.RequestBody.Encoding[fieldType.Name] + if ok { + enc = &en + } + } + + if err := mfb.evalMultipartFieldValueRecursive(w, fieldType.Name, fieldValue, &fieldInfo, enc); err != nil { + return err + } + } -// WriteField calls CreateFormField and then writes the given value. -func (w *MultipartWriter) WriteField(fieldName, value string, headers http.Header) error { - h := createFieldMIMEHeader(fieldName, headers) - p, err := w.CreatePart(h) - if err != nil { - return err + return nil + } } - _, err = p.Write([]byte(value)) - return err + return fmt.Errorf("invalid multipart form body, expected object, got %v", bodyInfo.Type) } -func createFieldMIMEHeader(fieldName string, headers http.Header) textproto.MIMEHeader { - h := make(textproto.MIMEHeader) - for key, header := range headers { - h[key] = header +func (mfb *MultipartFormEncoder) evalMultipartFieldValueRecursive(w *MultipartWriter, name string, value reflect.Value, fieldInfo *rest.ObjectField, enc *rest.EncodingObject) error { + underlyingValue, notNull := utils.UnwrapPointerFromReflectValue(value) + argTypeT, err := fieldInfo.Type.InterfaceT() + switch argType := argTypeT.(type) { + case *schema.ArrayType: + if !notNull { + return fmt.Errorf("%s: %w", name, errArgumentRequired) + } + if enc != nil && slices.Contains(enc.ContentType, rest.ContentTypeJSON) { + var headers http.Header + var err error + if len(enc.Headers) > 0 { + headers, err = mfb.evalEncodingHeaders(enc.Headers) + if err != nil { + return err + } + } + + return w.WriteJSON(name, value.Interface(), headers) + } + + if !slices.Contains([]reflect.Kind{reflect.Slice, reflect.Array}, value.Kind()) { + return fmt.Errorf("%s: expected array type, got %v", name, value.Kind()) + } + + for i := range value.Len() { + elem := value.Index(i) + err := mfb.evalMultipartFieldValueRecursive(w, name+"[]", elem, &rest.ObjectField{ + ObjectField: schema.ObjectField{ + Type: argType.ElementType, + }, + HTTP: fieldInfo.HTTP.Items, + }, enc) + if err != nil { + return err + } + } + + return nil + case *schema.NullableType: + if !notNull { + return nil + } + + return mfb.evalMultipartFieldValueRecursive(w, name, underlyingValue, &rest.ObjectField{ + ObjectField: schema.ObjectField{ + Type: argType.UnderlyingType, + }, + HTTP: fieldInfo.HTTP, + }, enc) + case *schema.NamedType: + if !notNull { + return fmt.Errorf("%s: %w", name, errArgumentRequired) + } + var headers http.Header + var err error + if enc != nil && len(enc.Headers) > 0 { + headers, err = mfb.evalEncodingHeaders(enc.Headers) + if err != nil { + return err + } + } + + if iScalar, ok := mfb.schema.ScalarTypes[argType.Name]; ok { + switch iScalar.Representation.Interface().(type) { + case *schema.TypeRepresentationBytes: + return w.WriteDataURI(name, value.Interface(), headers) + default: + } + } + + if enc != nil && slices.Contains(enc.ContentType, rest.ContentTypeJSON) { + return w.WriteJSON(name, value, headers) + } + + params, err := mfb.paramEncoder.EncodeParameterValues(fieldInfo, value, []string{}) + if err != nil { + return err + } + + if len(params) == 0 { + return nil + } + + for _, p := range params { + keys := p.Keys() + values := p.Values() + fieldName := name + + if len(keys) > 0 { + keys = append([]Key{NewKey(name)}, keys...) + fieldName = keys.String() + } + + if len(values) > 1 { + fieldName += "[]" + for _, v := range values { + if err = w.WriteField(fieldName, v, headers); err != nil { + return err + } + } + } else if len(values) == 1 { + if err = w.WriteField(fieldName, values[0], headers); err != nil { + return err + } + } + } + + return nil + case *schema.PredicateType: + return fmt.Errorf("%s: predicate type is not supported", name) + default: + return fmt.Errorf("%s: %w", name, err) + } +} + +func (mfb *MultipartFormEncoder) evalEncodingHeaders(encHeaders map[string]rest.RequestParameter) (http.Header, error) { + results := http.Header{} + for key, param := range encHeaders { + argumentName := param.ArgumentName + if argumentName == "" { + argumentName = key + } + argumentInfo, ok := mfb.operation.Arguments[argumentName] + if !ok { + continue + } + rawHeaderValue, ok := mfb.arguments[argumentName] + if !ok { + continue + } + + headerParams, err := mfb.paramEncoder.EncodeParameterValues(&rest.ObjectField{ + ObjectField: schema.ObjectField{ + Type: argumentInfo.Type, + }, + HTTP: param.Schema, + }, reflect.ValueOf(rawHeaderValue), []string{}) + if err != nil { + return nil, err + } + + param.Name = key + SetHeaderParameters(&results, ¶m, headerParams) } - h.Set("Content-Disposition", - fmt.Sprintf(`form-data; name="%s"`, escapeQuotes(fieldName))) - return h + return results, nil } diff --git a/connector/internal/contenttype/multipart_test.go b/connector/internal/contenttype/multipart_test.go new file mode 100644 index 0000000..88db59f --- /dev/null +++ b/connector/internal/contenttype/multipart_test.go @@ -0,0 +1,129 @@ +package contenttype + +import ( + "encoding/json" + "io" + "mime" + "mime/multipart" + "net/http" + "strings" + "testing" + + rest "github.com/hasura/ndc-http/ndc-http-schema/schema" + "gotest.tools/v3/assert" +) + +func TestCreateMultipartForm(t *testing.T) { + testCases := []struct { + Name string + RawArguments string + Expected map[string]string + ExpectedHeaders map[string]http.Header + }{ + { + Name: "PostFiles", + RawArguments: `{ + "body": { + "expand": ["foo"], + "expand_json": ["foo","bar"], + "file": "aGVsbG8gd29ybGQ=", + "file_link_data": { + "create": true, + "expires_at": 181320689 + }, + "purpose": "business_icon" + }, + "headerXRateLimitLimit": 10 + }`, + Expected: map[string]string{ + "expand[]": `foo`, + "expand_json": `["foo","bar"]`, + "file": "hello world", + "file_link_data.create": "true", + "file_link_data.expires_at": "181320689", + "purpose": "business_icon", + }, + ExpectedHeaders: map[string]http.Header{ + "expand[]": { + "Content-Type": []string{rest.ContentTypeTextPlain}, + }, + "expand_json": { + "Content-Type": []string{"application/json"}, + }, + "file": { + "Content-Type": []string{rest.ContentTypeOctetStream}, + "X-Rate-Limit-Limit": []string{"10"}, + }, + }, + }, + { + Name: "uploadPetMultipart", + RawArguments: `{ + "body": { + "address": { + "street": "street 1", + "location": [0, 1] + } + } + }`, + Expected: map[string]string{ + "address": `{"location":[0,1],"street":"street 1"}`, + }, + ExpectedHeaders: map[string]http.Header{}, + }, + } + + ndcSchema := createMockSchema(t) + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + var info *rest.OperationInfo + for key, f := range ndcSchema.Procedures { + if key == tc.Name { + info = &f + break + } + } + assert.Assert(t, info != nil) + + var arguments map[string]any + assert.NilError(t, json.Unmarshal([]byte(tc.RawArguments), &arguments)) + builder := NewMultipartFormEncoder(ndcSchema, info, arguments) + buf, mediaType, err := builder.Encode(arguments["body"]) + assert.NilError(t, err) + + _, params, err := mime.ParseMediaType(mediaType) + assert.NilError(t, err) + + reader := multipart.NewReader(buf, params["boundary"]) + var count int + results := make(map[string]string) + for { + form, err := reader.NextPart() + if err != nil && strings.Contains(err.Error(), io.EOF.Error()) { + break + } + assert.NilError(t, err) + count++ + name := form.FormName() + + expected, ok := tc.Expected[name] + if !ok { + t.Fatalf("field %s does not exist", name) + } else { + result, err := io.ReadAll(form) + assert.NilError(t, err) + assert.Equal(t, expected, string(result)) + results[name] = string(result) + expectedHeader := tc.ExpectedHeaders[name] + + for key, value := range expectedHeader { + assert.DeepEqual(t, value, form.Header[key]) + } + } + } + if len(tc.Expected) != count { + assert.DeepEqual(t, tc.Expected, results) + } + }) + } +} diff --git a/connector/internal/contenttype/multipart_writer.go b/connector/internal/contenttype/multipart_writer.go new file mode 100644 index 0000000..819113d --- /dev/null +++ b/connector/internal/contenttype/multipart_writer.go @@ -0,0 +1,104 @@ +package contenttype + +import ( + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/textproto" + + "github.com/hasura/ndc-http/ndc-http-schema/schema" + "github.com/hasura/ndc-sdk-go/utils" +) + +// MultipartWriter extends multipart.Writer with helpers +type MultipartWriter struct { + *multipart.Writer +} + +// NewMultipartWriter creates a MultipartWriter instance +func NewMultipartWriter(w io.Writer) *MultipartWriter { + return &MultipartWriter{multipart.NewWriter(w)} +} + +// WriteDataURI write a file from data URI string +func (w *MultipartWriter) WriteDataURI(name string, value any, headers http.Header) error { + b64, err := utils.DecodeString(value) + if err != nil { + return fmt.Errorf("%s: %w", name, err) + } + dataURI, err := DecodeDataURI(b64) + if err != nil { + return fmt.Errorf("%s: %w", name, err) + } + + h := make(textproto.MIMEHeader) + for key, header := range headers { + h[key] = header + } + h.Set("Content-Disposition", + fmt.Sprintf(`form-data; name="%s"; filename="%s"`, + escapeQuotes(name), escapeQuotes(name))) + + if dataURI.MediaType == "" { + h.Set("Content-Type", schema.ContentTypeOctetStream) + } else { + h.Set("Content-Type", dataURI.MediaType) + } + + p, err := w.CreatePart(h) + if err != nil { + return fmt.Errorf("%s: %w", name, err) + } + + _, err = p.Write([]byte(dataURI.Data)) + + return err +} + +// WriteField calls CreateFormField and then writes the given value with json encoding. +func (w *MultipartWriter) WriteJSON(fieldName string, value any, headers http.Header) error { + bs, err := json.Marshal(value) + if err != nil { + return err + } + + h := createFieldMIMEHeader(fieldName, headers) + h.Set(schema.ContentTypeHeader, schema.ContentTypeJSON) + p, err := w.CreatePart(h) + if err != nil { + return err + } + + _, err = p.Write(bs) + + return err +} + +// WriteField calls CreateFormField and then writes the given value. +func (w *MultipartWriter) WriteField(fieldName, value string, headers http.Header) error { + h := createFieldMIMEHeader(fieldName, headers) + if h.Get(schema.ContentTypeHeader) == "" { + h.Set(schema.ContentTypeHeader, schema.ContentTypeTextPlain) + } + + p, err := w.CreatePart(h) + if err != nil { + return err + } + _, err = p.Write([]byte(value)) + + return err +} + +func createFieldMIMEHeader(fieldName string, headers http.Header) textproto.MIMEHeader { + h := make(textproto.MIMEHeader) + for key, header := range headers { + h[key] = header + } + h.Set("Content-Disposition", + fmt.Sprintf(`form-data; name="%s"`, escapeQuotes(fieldName))) + + return h +} diff --git a/connector/internal/parameter.go b/connector/internal/contenttype/parameter.go similarity index 99% rename from connector/internal/parameter.go rename to connector/internal/contenttype/parameter.go index af7f9f8..4c4e4fe 100644 --- a/connector/internal/parameter.go +++ b/connector/internal/contenttype/parameter.go @@ -1,4 +1,4 @@ -package internal +package contenttype import ( "fmt" diff --git a/connector/internal/contenttype/url_encode.go b/connector/internal/contenttype/url_encode.go new file mode 100644 index 0000000..5d35e20 --- /dev/null +++ b/connector/internal/contenttype/url_encode.go @@ -0,0 +1,450 @@ +package contenttype + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "reflect" + "slices" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + rest "github.com/hasura/ndc-http/ndc-http-schema/schema" + "github.com/hasura/ndc-sdk-go/schema" + "github.com/hasura/ndc-sdk-go/utils" +) + +// URLParameterEncoder represents a URL parameter encoder. +type URLParameterEncoder struct { + schema *rest.NDCHttpSchema +} + +// NewURLParameterEncoder creates a URLParameterEncoder instance. +func NewURLParameterEncoder(schema *rest.NDCHttpSchema) *URLParameterEncoder { + return &URLParameterEncoder{schema: schema} +} + +func (c *URLParameterEncoder) Encode(bodyInfo *rest.ArgumentInfo, bodyData any) (io.ReadSeeker, error) { + queryParams, err := c.EncodeParameterValues(&rest.ObjectField{ + ObjectField: schema.ObjectField{ + Type: bodyInfo.Type, + }, + HTTP: bodyInfo.HTTP.Schema, + }, reflect.ValueOf(bodyData), []string{"body"}) + if err != nil { + return nil, err + } + + if len(queryParams) == 0 { + return nil, nil + } + q := url.Values{} + for _, qp := range queryParams { + keys := qp.Keys() + EvalQueryParameterURL(&q, "", bodyInfo.HTTP.EncodingObject, keys, qp.Values()) + } + rawQuery := EncodeQueryValues(q, true) + + return bytes.NewReader([]byte(rawQuery)), nil +} + +func (c *URLParameterEncoder) EncodeParameterValues(objectField *rest.ObjectField, reflectValue reflect.Value, fieldPaths []string) (ParameterItems, error) { + results := ParameterItems{} + + typeSchema := objectField.HTTP + reflectValue, nonNull := utils.UnwrapPointerFromReflectValue(reflectValue) + + switch ty := objectField.Type.Interface().(type) { + case *schema.NullableType: + if !nonNull { + return results, nil + } + + return c.EncodeParameterValues(&rest.ObjectField{ + ObjectField: schema.ObjectField{ + Type: ty.UnderlyingType, + }, + HTTP: typeSchema, + }, reflectValue, fieldPaths) + case *schema.ArrayType: + if !nonNull { + return nil, fmt.Errorf("%s: %w", strings.Join(fieldPaths, ""), errArgumentRequired) + } + + elements, ok := reflectValue.Interface().([]any) + if !ok { + return nil, fmt.Errorf("%s: expected array, got <%s> %v", strings.Join(fieldPaths, ""), reflectValue.Kind(), reflectValue.Interface()) + } + + for i, elem := range elements { + outputs, err := c.EncodeParameterValues(&rest.ObjectField{ + ObjectField: schema.ObjectField{ + Type: ty.ElementType, + }, + HTTP: typeSchema.Items, + }, reflect.ValueOf(elem), append(fieldPaths, "["+strconv.Itoa(i)+"]")) + if err != nil { + return nil, err + } + + for _, output := range outputs { + results.Add(append([]Key{NewIndexKey(i)}, output.Keys()...), output.Values()) + } + } + + return results, nil + case *schema.NamedType: + if !nonNull { + return nil, fmt.Errorf("%s: %w", strings.Join(fieldPaths, ""), errArgumentRequired) + } + iScalar, ok := c.schema.ScalarTypes[ty.Name] + if ok { + return encodeScalarParameterReflectionValues(reflectValue, &iScalar, fieldPaths) + } + kind := reflectValue.Kind() + objectInfo, ok := c.schema.ObjectTypes[ty.Name] + if !ok { + return nil, fmt.Errorf("%s: invalid type %s", strings.Join(fieldPaths, ""), ty.Name) + } + + switch kind { + case reflect.Map, reflect.Interface: + anyValue := reflectValue.Interface() + object, ok := anyValue.(map[string]any) + if !ok { + return nil, fmt.Errorf("%s: failed to evaluate object, got <%s> %v", strings.Join(fieldPaths, ""), kind, anyValue) + } + for key, fieldInfo := range objectInfo.Fields { + fieldVal := object[key] + output, err := c.EncodeParameterValues(&fieldInfo, reflect.ValueOf(fieldVal), append(fieldPaths, "."+key)) + if err != nil { + return nil, err + } + + for _, pair := range output { + results.Add(append([]Key{NewKey(key)}, pair.Keys()...), pair.Values()) + } + } + case reflect.Struct: + reflectType := reflectValue.Type() + for fieldIndex := range reflectValue.NumField() { + fieldVal := reflectValue.Field(fieldIndex) + fieldType := reflectType.Field(fieldIndex) + fieldInfo, ok := objectInfo.Fields[fieldType.Name] + if !ok { + continue + } + + output, err := c.EncodeParameterValues(&fieldInfo, fieldVal, append(fieldPaths, "."+fieldType.Name)) + if err != nil { + return nil, err + } + + for _, pair := range output { + results.Add(append([]Key{NewKey(fieldType.Name)}, pair.Keys()...), pair.Values()) + } + } + default: + return nil, fmt.Errorf("%s: failed to evaluate object, got %s", strings.Join(fieldPaths, ""), kind) + } + + return results, nil + } + + return nil, fmt.Errorf("%s: invalid type %v", strings.Join(fieldPaths, ""), objectField.Type) +} + +func encodeScalarParameterReflectionValues(reflectValue reflect.Value, scalar *schema.ScalarType, fieldPaths []string) (ParameterItems, error) { + switch sl := scalar.Representation.Interface().(type) { + case *schema.TypeRepresentationBoolean: + value, err := utils.DecodeBooleanReflection(reflectValue) + if err != nil { + return nil, fmt.Errorf("%s: %w", strings.Join(fieldPaths, ""), err) + } + + return []ParameterItem{ + NewParameterItem([]Key{}, []string{strconv.FormatBool(value)}), + }, nil + case *schema.TypeRepresentationString, *schema.TypeRepresentationBytes: + value, err := utils.DecodeStringReflection(reflectValue) + if err != nil { + return nil, fmt.Errorf("%s: %w", strings.Join(fieldPaths, ""), err) + } + + return []ParameterItem{NewParameterItem([]Key{}, []string{value})}, nil + case *schema.TypeRepresentationInteger, *schema.TypeRepresentationInt8, *schema.TypeRepresentationInt16, *schema.TypeRepresentationInt32, *schema.TypeRepresentationInt64, *schema.TypeRepresentationBigInteger: //nolint:all + value, err := utils.DecodeIntReflection[int64](reflectValue) + if err != nil { + return nil, fmt.Errorf("%s: %w", strings.Join(fieldPaths, ""), err) + } + + return []ParameterItem{ + NewParameterItem([]Key{}, []string{strconv.FormatInt(value, 10)}), + }, nil + case *schema.TypeRepresentationNumber, *schema.TypeRepresentationFloat32, *schema.TypeRepresentationFloat64, *schema.TypeRepresentationBigDecimal: //nolint:all + value, err := utils.DecodeFloatReflection[float64](reflectValue) + if err != nil { + return nil, fmt.Errorf("%s: %w", strings.Join(fieldPaths, ""), err) + } + + return []ParameterItem{ + NewParameterItem([]Key{}, []string{fmt.Sprint(value)}), + }, nil + case *schema.TypeRepresentationEnum: + value, err := utils.DecodeStringReflection(reflectValue) + if err != nil { + return nil, fmt.Errorf("%s: %w", strings.Join(fieldPaths, ""), err) + } + + if !slices.Contains(sl.OneOf, value) { + return nil, fmt.Errorf("%s: the value must be one of %v, got %s", strings.Join(fieldPaths, ""), sl.OneOf, value) + } + + return []ParameterItem{NewParameterItem([]Key{}, []string{value})}, nil + case *schema.TypeRepresentationDate: + value, err := utils.DecodeDateTimeReflection(reflectValue) + if err != nil { + return nil, fmt.Errorf("%s: %w", strings.Join(fieldPaths, ""), err) + } + + return []ParameterItem{ + NewParameterItem([]Key{}, []string{value.Format(time.DateOnly)}), + }, nil + case *schema.TypeRepresentationTimestamp, *schema.TypeRepresentationTimestampTZ: + value, err := utils.DecodeDateTimeReflection(reflectValue) + if err != nil { + return nil, fmt.Errorf("%s: %w", strings.Join(fieldPaths, ""), err) + } + + return []ParameterItem{ + NewParameterItem([]Key{}, []string{value.Format(time.RFC3339)}), + }, nil + case *schema.TypeRepresentationUUID: + rawValue, err := utils.DecodeStringReflection(reflectValue) + if err != nil { + return nil, fmt.Errorf("%s: %w", strings.Join(fieldPaths, ""), err) + } + + _, err = uuid.Parse(rawValue) + if err != nil { + return nil, fmt.Errorf("%s: %w", strings.Join(fieldPaths, ""), err) + } + + return []ParameterItem{NewParameterItem([]Key{}, []string{rawValue})}, nil + default: + return encodeParameterReflectionValues(reflectValue, fieldPaths) + } +} + +func encodeParameterReflectionValues(reflectValue reflect.Value, fieldPaths []string) (ParameterItems, error) { + reflectValue, ok := utils.UnwrapPointerFromReflectValue(reflectValue) + if !ok { + return ParameterItems{}, nil + } + + kind := reflectValue.Kind() + if result, err := StringifySimpleScalar(reflectValue, kind); err == nil { + return []ParameterItem{ + NewParameterItem([]Key{}, []string{result}), + }, nil + } + + switch kind { + case reflect.Slice, reflect.Array: + return encodeParameterReflectionSlice(reflectValue, fieldPaths) + case reflect.Map, reflect.Interface: + return encodeParameterReflectionMap(reflectValue, fieldPaths) + case reflect.Struct: + return encodeParameterReflectionStruct(reflectValue, fieldPaths) + default: + return nil, fmt.Errorf("%s: failed to encode parameter, got %s", strings.Join(fieldPaths, ""), kind) + } +} + +func encodeParameterReflectionSlice(reflectValue reflect.Value, fieldPaths []string) (ParameterItems, error) { + results := ParameterItems{} + valueLen := reflectValue.Len() + for i := range valueLen { + elem := reflectValue.Index(i) + outputs, err := encodeParameterReflectionValues(elem, append(fieldPaths, fmt.Sprintf("[%d]", i))) + if err != nil { + return nil, err + } + + for _, output := range outputs { + results.Add(append([]Key{NewIndexKey(i)}, output.Keys()...), output.Values()) + } + } + + return results, nil +} + +func encodeParameterReflectionStruct(reflectValue reflect.Value, fieldPaths []string) (ParameterItems, error) { + results := ParameterItems{} + reflectType := reflectValue.Type() + for fieldIndex := range reflectValue.NumField() { + fieldVal := reflectValue.Field(fieldIndex) + fieldType := reflectType.Field(fieldIndex) + output, err := encodeParameterReflectionValues(fieldVal, append(fieldPaths, "."+fieldType.Name)) + if err != nil { + return nil, err + } + + for _, pair := range output { + results.Add(append([]Key{NewKey(fieldType.Name)}, pair.Keys()...), pair.Values()) + } + } + + return results, nil +} + +func encodeParameterReflectionMap(reflectValue reflect.Value, fieldPaths []string) (ParameterItems, error) { + results := ParameterItems{} + anyValue := reflectValue.Interface() + object, ok := anyValue.(map[string]any) + if !ok { + b, err := json.Marshal(anyValue) + if err != nil { + return nil, fmt.Errorf("%s: %w", strings.Join(fieldPaths, ""), err) + } + values := []string{strings.Trim(string(b), `"`)} + + return []ParameterItem{NewParameterItem([]Key{}, values)}, nil + } + + for key, fieldValue := range object { + output, err := encodeParameterReflectionValues(reflect.ValueOf(fieldValue), append(fieldPaths, "."+key)) + if err != nil { + return nil, err + } + + for _, pair := range output { + results.Add(append([]Key{NewKey(key)}, pair.Keys()...), pair.Values()) + } + } + + return results, nil +} + +func buildParamQueryKey(name string, encObject rest.EncodingObject, keys Keys, values []string) string { + resultKeys := []string{} + if name != "" { + resultKeys = append(resultKeys, name) + } + keysLength := len(keys) + // non-explode or explode form object does not require param name + // /users?role=admin&firstName=Alex + if (encObject.Explode != nil && !*encObject.Explode) || + (len(values) == 1 && encObject.Style == rest.EncodingStyleForm && (keysLength > 1 || (keysLength == 1 && !keys[0].IsEmpty()))) { + resultKeys = []string{} + } + + if keysLength > 0 { + if encObject.Style != rest.EncodingStyleDeepObject && keys[keysLength-1].IsEmpty() { + keys = keys[:keysLength-1] + } + + for i, key := range keys { + if len(resultKeys) == 0 { + resultKeys = append(resultKeys, key.String()) + + continue + } + if i == len(keys)-1 && key.Index() != nil { + // the last element of array in the deepObject style doesn't have index + resultKeys = append(resultKeys, "[]") + + continue + } + + resultKeys = append(resultKeys, "["+key.String()+"]") + } + } + + return strings.Join(resultKeys, "") +} + +func EvalQueryParameterURL(q *url.Values, name string, encObject rest.EncodingObject, keys Keys, values []string) { + if len(values) == 0 { + return + } + paramKey := buildParamQueryKey(name, encObject, keys, values) + // encode explode queries, e.g /users?id=3&id=4&id=5 + if encObject.Explode == nil || *encObject.Explode { + for _, value := range values { + q.Add(paramKey, value) + } + + return + } + + switch encObject.Style { + case rest.EncodingStyleSpaceDelimited: + q.Add(name, strings.Join(values, " ")) + case rest.EncodingStylePipeDelimited: + q.Add(name, strings.Join(values, "|")) + // default style is form + default: + paramValues := values + if paramKey != "" { + paramValues = append([]string{paramKey}, paramValues...) + } + q.Add(name, strings.Join(paramValues, ",")) + } +} + +func EncodeQueryValues(qValues url.Values, allowReserved bool) string { + if !allowReserved { + return qValues.Encode() + } + + var builder strings.Builder + index := 0 + for key, values := range qValues { + for i, value := range values { + if index > 0 || i > 0 { + builder.WriteRune('&') + } + builder.WriteString(key) + builder.WriteRune('=') + builder.WriteString(value) + } + index++ + } + + return builder.String() +} + +func SetHeaderParameters(header *http.Header, param *rest.RequestParameter, queryParams ParameterItems) { + defaultParam := queryParams.FindDefault() + // the param is an array + if defaultParam != nil { + header.Set(param.Name, strings.Join(defaultParam.Values(), ",")) + + return + } + + if param.Explode != nil && *param.Explode { + var headerValues []string + for _, pair := range queryParams { + headerValues = append(headerValues, fmt.Sprintf("%s=%s", pair.Keys().String(), strings.Join(pair.Values(), ","))) + } + header.Set(param.Name, strings.Join(headerValues, ",")) + + return + } + + var headerValues []string + for _, pair := range queryParams { + pairKey := pair.Keys().String() + for _, v := range pair.Values() { + headerValues = append(headerValues, pairKey, v) + } + } + header.Set(param.Name, strings.Join(headerValues, ",")) +} diff --git a/connector/internal/request_builder_test.go b/connector/internal/contenttype/url_encode_test.go similarity index 79% rename from connector/internal/request_builder_test.go rename to connector/internal/contenttype/url_encode_test.go index 251aeba..ff3d1c6 100644 --- a/connector/internal/request_builder_test.go +++ b/connector/internal/contenttype/url_encode_test.go @@ -1,131 +1,249 @@ -package internal +package contenttype import ( "encoding/json" "io" - "mime" - "mime/multipart" - "net/http" - "os" + "net/url" "slices" "strings" "testing" rest "github.com/hasura/ndc-http/ndc-http-schema/schema" + "github.com/hasura/ndc-sdk-go/utils" "gotest.tools/v3/assert" ) -func TestCreateMultipartForm(t *testing.T) { +func TestEvalQueryParameterURL(t *testing.T) { testCases := []struct { - Name string - RawArguments string - Expected map[string]string - ExpectedHeaders map[string]http.Header + name string + param *rest.RequestParameter + keys []Key + values []string + expected string }{ { - Name: "PostFiles", - RawArguments: `{ - "body": { - "expand": ["foo"], - "expand_json": ["foo","bar"], - "file": "aGVsbG8gd29ybGQ=", - "file_link_data": { - "create": true, - "expires_at": 181320689 - }, - "purpose": "business_icon" - }, - "headerXRateLimitLimit": 10 - }`, - Expected: map[string]string{ - "expand[]": `foo`, - "expand_json": `["foo","bar"]`, - "file": "hello world", - "file_link_data.create": "true", - "file_link_data.expires_at": "181320689", - "purpose": "business_icon", + name: "empty", + param: &rest.RequestParameter{}, + keys: []Key{NewKey("")}, + values: []string{}, + expected: "", + }, + { + name: "form_explode_single", + param: &rest.RequestParameter{ + Name: "id", + EncodingObject: rest.EncodingObject{ + Explode: utils.ToPtr(true), + Style: rest.EncodingStyleForm, + }, }, - ExpectedHeaders: map[string]http.Header{ - "expand": { - "Content-Type": []string{"application/json"}, + keys: []Key{}, + values: []string{"3"}, + expected: "id=3", + }, + { + name: "form_single", + param: &rest.RequestParameter{ + Name: "id", + EncodingObject: rest.EncodingObject{ + Explode: utils.ToPtr(false), + Style: rest.EncodingStyleForm, }, - "file": { - "X-Rate-Limit-Limit": []string{"10"}, + }, + keys: []Key{NewKey("")}, + values: []string{"3"}, + expected: "id=3", + }, + { + name: "form_explode_multiple", + param: &rest.RequestParameter{ + Name: "id", + EncodingObject: rest.EncodingObject{ + Explode: utils.ToPtr(true), + Style: rest.EncodingStyleForm, }, }, + keys: []Key{NewKey("")}, + values: []string{"3", "4", "5"}, + expected: "id=3&id=4&id=5", }, { - Name: "uploadPetMultipart", - RawArguments: `{ - "body": { - "address": { - "street": "street 1", - "location": [0, 1] - } - } - }`, - Expected: map[string]string{ - "address": `{"location":[0,1],"street":"street 1"}`, + name: "spaceDelimited_multiple", + param: &rest.RequestParameter{ + Name: "id", + EncodingObject: rest.EncodingObject{ + Explode: utils.ToPtr(false), + Style: rest.EncodingStyleSpaceDelimited, + }, + }, + keys: []Key{NewKey("")}, + values: []string{"3", "4", "5"}, + expected: "id=3 4 5", + }, + { + name: "spaceDelimited_explode_multiple", + param: &rest.RequestParameter{ + Name: "id", + EncodingObject: rest.EncodingObject{ + Explode: utils.ToPtr(true), + Style: rest.EncodingStyleSpaceDelimited, + }, + }, + keys: []Key{NewKey("")}, + values: []string{"3", "4", "5"}, + expected: "id=3&id=4&id=5", + }, + + { + name: "pipeDelimited_multiple", + param: &rest.RequestParameter{ + Name: "id", + EncodingObject: rest.EncodingObject{ + Explode: utils.ToPtr(false), + Style: rest.EncodingStylePipeDelimited, + }, + }, + keys: []Key{NewKey("")}, + values: []string{"3", "4", "5"}, + expected: "id=3|4|5", + }, + { + name: "pipeDelimited_explode_multiple", + param: &rest.RequestParameter{ + Name: "id", + EncodingObject: rest.EncodingObject{ + Explode: utils.ToPtr(true), + Style: rest.EncodingStylePipeDelimited, + }, + }, + keys: []Key{NewKey("")}, + values: []string{"3", "4", "5"}, + expected: "id=3&id=4&id=5", + }, + { + name: "deepObject_explode_multiple", + param: &rest.RequestParameter{ + Name: "id", + EncodingObject: rest.EncodingObject{ + Explode: utils.ToPtr(true), + Style: rest.EncodingStyleDeepObject, + }, + }, + keys: []Key{NewKey("")}, + values: []string{"3", "4", "5"}, + expected: "id[]=3&id[]=4&id[]=5", + }, + { + name: "form_object", + param: &rest.RequestParameter{ + Name: "id", + EncodingObject: rest.EncodingObject{ + Explode: utils.ToPtr(false), + Style: rest.EncodingStyleForm, + }, + }, + keys: []Key{NewKey("role")}, + values: []string{"admin"}, + expected: "id=role,admin", + }, + { + name: "form_explode_object", + param: &rest.RequestParameter{ + Name: "id", + EncodingObject: rest.EncodingObject{ + Explode: utils.ToPtr(true), + Style: rest.EncodingStyleForm, + }, + }, + keys: []Key{NewKey("role")}, + values: []string{"admin"}, + expected: "role=admin", + }, + { + name: "deepObject_explode_object", + param: &rest.RequestParameter{ + Name: "id", + EncodingObject: rest.EncodingObject{ + Explode: utils.ToPtr(true), + Style: rest.EncodingStyleDeepObject, + }, + }, + keys: []Key{NewKey("role")}, + values: []string{"admin"}, + expected: "id[role]=admin", + }, + { + name: "form_array_object", + param: &rest.RequestParameter{ + Name: "id", + EncodingObject: rest.EncodingObject{ + Explode: utils.ToPtr(false), + Style: rest.EncodingStyleForm, + }, }, - ExpectedHeaders: map[string]http.Header{}, + keys: []Key{NewKey("role"), NewKey(""), NewKey("user"), NewKey("")}, + values: []string{"admin"}, + expected: "id=role[][user],admin", + }, + { + name: "form_explode_array_object", + param: &rest.RequestParameter{ + Name: "id", + EncodingObject: rest.EncodingObject{ + Explode: utils.ToPtr(true), + Style: rest.EncodingStyleForm, + }, + }, + keys: []Key{NewKey("role"), NewKey(""), NewKey("user"), NewKey("")}, + values: []string{"admin"}, + expected: "role[][user]=admin", + }, + { + name: "form_explode_array_object_multiple", + param: &rest.RequestParameter{ + Name: "id", + EncodingObject: rest.EncodingObject{ + Explode: utils.ToPtr(true), + Style: rest.EncodingStyleForm, + }, + }, + keys: []Key{NewKey("role"), NewKey(""), NewKey("user"), NewKey("")}, + values: []string{"admin", "anonymous"}, + expected: "id[role][][user]=admin&id[role][][user]=anonymous", + }, + { + name: "deepObject_explode_array_object", + param: &rest.RequestParameter{ + Name: "id", + EncodingObject: rest.EncodingObject{ + Explode: utils.ToPtr(true), + Style: rest.EncodingStyleDeepObject, + }, + }, + keys: []Key{NewKey("role"), NewKey(""), NewKey("user"), NewKey("")}, + values: []string{"admin"}, + expected: "id[role][][user][]=admin", + }, + { + name: "deepObject_explode_array_object_multiple", + param: &rest.RequestParameter{ + Name: "id", + EncodingObject: rest.EncodingObject{ + Explode: utils.ToPtr(true), + Style: rest.EncodingStyleDeepObject, + }, + }, + keys: []Key{NewKey("role"), NewKey(""), NewKey("user"), NewKey("")}, + values: []string{"admin", "anonymous"}, + expected: "id[role][][user][]=admin&id[role][][user][]=anonymous", }, } - ndcSchema := createMockSchema(t) for _, tc := range testCases { - t.Run(tc.Name, func(t *testing.T) { - var info *rest.OperationInfo - for key, f := range ndcSchema.Procedures { - if key == tc.Name { - info = &f - break - } - } - assert.Assert(t, info != nil) - - var arguments map[string]any - assert.NilError(t, json.Unmarshal([]byte(tc.RawArguments), &arguments)) - builder := RequestBuilder{ - Schema: ndcSchema, - Operation: info, - Arguments: arguments, - } - buf, mediaType, err := builder.createMultipartForm(arguments["body"]) - assert.NilError(t, err) - - _, params, err := mime.ParseMediaType(mediaType) - assert.NilError(t, err) - - reader := multipart.NewReader(buf, params["boundary"]) - var count int - results := make(map[string]string) - for { - form, err := reader.NextPart() - if err != nil && strings.Contains(err.Error(), io.EOF.Error()) { - break - } - assert.NilError(t, err) - count++ - name := form.FormName() - - expected, ok := tc.Expected[name] - if !ok { - t.Fatalf("field %s does not exist", name) - } else { - result, err := io.ReadAll(form) - assert.NilError(t, err) - assert.Equal(t, expected, string(result)) - results[name] = string(result) - expectedHeader := tc.ExpectedHeaders[name] - - for key, value := range expectedHeader { - assert.DeepEqual(t, value, form.Header[key]) - } - } - } - if len(tc.Expected) != count { - assert.DeepEqual(t, tc.Expected, results) - } + t.Run(tc.name, func(t *testing.T) { + qValues := make(url.Values) + EvalQueryParameterURL(&qValues, tc.param.Name, tc.param.EncodingObject, tc.keys, tc.values) + assert.Equal(t, tc.expected, EncodeQueryValues(qValues, true)) }) } } @@ -573,12 +691,8 @@ func TestCreateFormURLEncoded(t *testing.T) { var arguments map[string]any assert.NilError(t, json.Unmarshal([]byte(tc.RawArguments), &arguments)) argumentInfo := info.Arguments["body"] - builder := RequestBuilder{ - Schema: ndcSchema, - Operation: info, - Arguments: arguments, - } - buf, err := builder.createFormURLEncoded(&argumentInfo, arguments["body"]) + builder := NewURLParameterEncoder(ndcSchema) + buf, err := builder.Encode(&argumentInfo, arguments["body"]) assert.NilError(t, err) result, err := io.ReadAll(buf) assert.NilError(t, err) @@ -586,12 +700,3 @@ func TestCreateFormURLEncoded(t *testing.T) { }) } } - -func createMockSchema(t *testing.T) *rest.NDCHttpSchema { - var ndcSchema rest.NDCHttpSchema - rawSchemaBytes, err := os.ReadFile("../../ndc-http-schema/openapi/testdata/petstore3/expected.json") - assert.NilError(t, err) - assert.NilError(t, json.Unmarshal(rawSchemaBytes, &ndcSchema)) - - return &ndcSchema -} diff --git a/connector/internal/contenttype/utils.go b/connector/internal/contenttype/utils.go index b0f77d5..f536857 100644 --- a/connector/internal/contenttype/utils.go +++ b/connector/internal/contenttype/utils.go @@ -2,6 +2,7 @@ package contenttype import ( "encoding/json" + "errors" "fmt" "reflect" "strconv" @@ -12,6 +13,11 @@ import ( var quoteEscaper = strings.NewReplacer("\\", "\\\\", `"`, "\\\"") +var ( + errArgumentRequired = errors.New("argument is required") + errRequestBodyTypeRequired = errors.New("failed to decode request body, empty body type") +) + func escapeQuotes(s string) string { return quoteEscaper.Replace(s) } diff --git a/connector/internal/request_builder.go b/connector/internal/request_builder.go index 9276b42..7907cd9 100644 --- a/connector/internal/request_builder.go +++ b/connector/internal/request_builder.go @@ -4,11 +4,6 @@ import ( "bytes" "encoding/json" "fmt" - "io" - "net/http" - "net/url" - "reflect" - "slices" "strings" "github.com/hasura/ndc-http/connector/internal/contenttype" @@ -115,7 +110,7 @@ func (c *RequestBuilder) buildRequestBody(request *RetryableRequest, rawRequest return nil case strings.HasPrefix(contentType, "multipart/"): - r, contentType, err := c.createMultipartForm(bodyData) + r, contentType, err := contenttype.NewMultipartFormEncoder(c.Schema, c.Operation, c.Arguments).Encode(bodyData) if err != nil { return err } @@ -126,7 +121,7 @@ func (c *RequestBuilder) buildRequestBody(request *RetryableRequest, rawRequest return nil case contentType == rest.ContentTypeFormURLEncoded: - r, err := c.createFormURLEncoded(&bodyInfo, bodyData) + r, err := contenttype.NewURLParameterEncoder(c.Schema).Encode(&bodyInfo, bodyData) if err != nil { return err } @@ -170,277 +165,6 @@ func (c *RequestBuilder) buildRequestBody(request *RetryableRequest, rawRequest return nil } -func (c *RequestBuilder) createFormURLEncoded(bodyInfo *rest.ArgumentInfo, bodyData any) (io.ReadSeeker, error) { - queryParams, err := c.encodeParameterValues(&rest.ObjectField{ - ObjectField: schema.ObjectField{ - Type: bodyInfo.Type, - }, - HTTP: bodyInfo.HTTP.Schema, - }, reflect.ValueOf(bodyData), []string{"body"}) - if err != nil { - return nil, err - } - - if len(queryParams) == 0 { - return nil, nil - } - q := url.Values{} - for _, qp := range queryParams { - keys := qp.Keys() - evalQueryParameterURL(&q, "", bodyInfo.HTTP.EncodingObject, keys, qp.Values()) - } - rawQuery := encodeQueryValues(q, true) - - return bytes.NewReader([]byte(rawQuery)), nil -} - -func (c *RequestBuilder) createMultipartForm(bodyData any) (*bytes.Reader, string, error) { - bodyInfo, ok := c.Operation.Arguments[rest.BodyKey] - if !ok { - return nil, "", errRequestBodyTypeRequired - } - - buffer := new(bytes.Buffer) - writer := contenttype.NewMultipartWriter(buffer) - - if err := c.evalMultipartForm(writer, &bodyInfo, reflect.ValueOf(bodyData)); err != nil { - return nil, "", err - } - if err := writer.Close(); err != nil { - return nil, "", err - } - - reader := bytes.NewReader(buffer.Bytes()) - buffer.Reset() - - return reader, writer.FormDataContentType(), nil -} - -func (c *RequestBuilder) evalMultipartForm(w *contenttype.MultipartWriter, bodyInfo *rest.ArgumentInfo, bodyData reflect.Value) error { - bodyData, ok := utils.UnwrapPointerFromReflectValue(bodyData) - if !ok { - return nil - } - switch bodyType := bodyInfo.Type.Interface().(type) { - case *schema.NullableType: - return c.evalMultipartForm(w, &rest.ArgumentInfo{ - ArgumentInfo: schema.ArgumentInfo{ - Type: bodyType.UnderlyingType, - }, - HTTP: bodyInfo.HTTP, - }, bodyData) - case *schema.NamedType: - if !ok { - return fmt.Errorf("%s: %w", rest.BodyKey, errArgumentRequired) - } - bodyObject, ok := c.Schema.ObjectTypes[bodyType.Name] - if !ok { - break - } - kind := bodyData.Kind() - switch kind { - case reflect.Map, reflect.Interface: - bi := bodyData.Interface() - bodyMap, ok := bi.(map[string]any) - if !ok { - return fmt.Errorf("invalid multipart form body, expected object, got %v", bi) - } - - for key, fieldInfo := range bodyObject.Fields { - fieldValue := bodyMap[key] - var enc *rest.EncodingObject - if len(c.Operation.Request.RequestBody.Encoding) > 0 { - en, ok := c.Operation.Request.RequestBody.Encoding[key] - if ok { - enc = &en - } - } - - if err := c.evalMultipartFieldValueRecursive(w, key, reflect.ValueOf(fieldValue), &fieldInfo, enc); err != nil { - return err - } - } - - return nil - case reflect.Struct: - reflectType := bodyData.Type() - for fieldIndex := range bodyData.NumField() { - fieldValue := bodyData.Field(fieldIndex) - fieldType := reflectType.Field(fieldIndex) - fieldInfo, ok := bodyObject.Fields[fieldType.Name] - if !ok { - continue - } - - var enc *rest.EncodingObject - if len(c.Operation.Request.RequestBody.Encoding) > 0 { - en, ok := c.Operation.Request.RequestBody.Encoding[fieldType.Name] - if ok { - enc = &en - } - } - - if err := c.evalMultipartFieldValueRecursive(w, fieldType.Name, fieldValue, &fieldInfo, enc); err != nil { - return err - } - } - - return nil - } - } - - return fmt.Errorf("invalid multipart form body, expected object, got %v", bodyInfo.Type) -} - -func (c *RequestBuilder) evalMultipartFieldValueRecursive(w *contenttype.MultipartWriter, name string, value reflect.Value, fieldInfo *rest.ObjectField, enc *rest.EncodingObject) error { - underlyingValue, notNull := utils.UnwrapPointerFromReflectValue(value) - argTypeT, err := fieldInfo.Type.InterfaceT() - switch argType := argTypeT.(type) { - case *schema.ArrayType: - if !notNull { - return fmt.Errorf("%s: %w", name, errArgumentRequired) - } - if enc != nil && slices.Contains(enc.ContentType, rest.ContentTypeJSON) { - var headers http.Header - var err error - if len(enc.Headers) > 0 { - headers, err = c.evalEncodingHeaders(enc.Headers) - if err != nil { - return err - } - } - - return w.WriteJSON(name, value.Interface(), headers) - } - - if !slices.Contains([]reflect.Kind{reflect.Slice, reflect.Array}, value.Kind()) { - return fmt.Errorf("%s: expected array type, got %v", name, value.Kind()) - } - - for i := range value.Len() { - elem := value.Index(i) - err := c.evalMultipartFieldValueRecursive(w, name+"[]", elem, &rest.ObjectField{ - ObjectField: schema.ObjectField{ - Type: argType.ElementType, - }, - HTTP: fieldInfo.HTTP.Items, - }, enc) - if err != nil { - return err - } - } - - return nil - case *schema.NullableType: - if !notNull { - return nil - } - - return c.evalMultipartFieldValueRecursive(w, name, underlyingValue, &rest.ObjectField{ - ObjectField: schema.ObjectField{ - Type: argType.UnderlyingType, - }, - HTTP: fieldInfo.HTTP, - }, enc) - case *schema.NamedType: - if !notNull { - return fmt.Errorf("%s: %w", name, errArgumentRequired) - } - var headers http.Header - var err error - if enc != nil && len(enc.Headers) > 0 { - headers, err = c.evalEncodingHeaders(enc.Headers) - if err != nil { - return err - } - } - - if iScalar, ok := c.Schema.ScalarTypes[argType.Name]; ok { - switch iScalar.Representation.Interface().(type) { - case *schema.TypeRepresentationBytes: - return w.WriteDataURI(name, value.Interface(), headers) - default: - } - } - - if enc != nil && slices.Contains(enc.ContentType, rest.ContentTypeJSON) { - return w.WriteJSON(name, value, headers) - } - - params, err := c.encodeParameterValues(fieldInfo, value, []string{}) - if err != nil { - return err - } - - if len(params) == 0 { - return nil - } - - for _, p := range params { - keys := p.Keys() - values := p.Values() - fieldName := name - - if len(keys) > 0 { - keys = append([]Key{NewKey(name)}, keys...) - fieldName = keys.String() - } - - if len(values) > 1 { - fieldName += "[]" - for _, v := range values { - if err = w.WriteField(fieldName, v, headers); err != nil { - return err - } - } - } else if len(values) == 1 { - if err = w.WriteField(fieldName, values[0], headers); err != nil { - return err - } - } - } - - return nil - case *schema.PredicateType: - return fmt.Errorf("%s: predicate type is not supported", name) - default: - return fmt.Errorf("%s: %w", name, err) - } -} - -func (c *RequestBuilder) evalEncodingHeaders(encHeaders map[string]rest.RequestParameter) (http.Header, error) { - results := http.Header{} - for key, param := range encHeaders { - argumentName := param.ArgumentName - if argumentName == "" { - argumentName = key - } - argumentInfo, ok := c.Operation.Arguments[argumentName] - if !ok { - continue - } - rawHeaderValue, ok := c.Arguments[argumentName] - if !ok { - continue - } - - headerParams, err := c.encodeParameterValues(&rest.ObjectField{ - ObjectField: schema.ObjectField{ - Type: argumentInfo.Type, - }, - HTTP: param.Schema, - }, reflect.ValueOf(rawHeaderValue), []string{}) - if err != nil { - return nil, err - } - - param.Name = key - setHeaderParameters(&results, ¶m, headerParams) - } - - return results, nil -} - func (c *RequestBuilder) getRequestUploadBody(rawRequest *rest.Request, bodyInfo *rest.ArgumentInfo) *rest.RequestBody { if rawRequest.RequestBody == nil || bodyInfo == nil { return nil diff --git a/connector/internal/request_parameter.go b/connector/internal/request_parameter.go index c3f3eea..f1f902f 100644 --- a/connector/internal/request_parameter.go +++ b/connector/internal/request_parameter.go @@ -1,22 +1,16 @@ package internal import ( - "encoding/json" "fmt" "net/http" "net/url" "reflect" "slices" - "strconv" "strings" - "time" - "github.com/google/uuid" "github.com/hasura/ndc-http/connector/internal/contenttype" rest "github.com/hasura/ndc-http/ndc-http-schema/schema" "github.com/hasura/ndc-sdk-go/schema" - "github.com/hasura/ndc-sdk-go/utils" - sdkUtils "github.com/hasura/ndc-sdk-go/utils" ) var urlAndHeaderLocations = []rest.ParameterLocation{rest.InPath, rest.InQuery, rest.InHeader} @@ -57,7 +51,7 @@ func (c *RequestBuilder) evalURLAndHeaderParameterBySchema(endpoint *url.URL, he if argumentInfo.HTTP.Name != "" { argumentKey = argumentInfo.HTTP.Name } - queryParams, err := c.encodeParameterValues(&rest.ObjectField{ + queryParams, err := contenttype.NewURLParameterEncoder(c.Schema).EncodeParameterValues(&rest.ObjectField{ ObjectField: schema.ObjectField{ Type: argumentInfo.Type, }, @@ -76,13 +70,13 @@ func (c *RequestBuilder) evalURLAndHeaderParameterBySchema(endpoint *url.URL, he // https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#parameter-object switch argumentInfo.HTTP.In { case rest.InHeader: - setHeaderParameters(header, argumentInfo.HTTP, queryParams) + contenttype.SetHeaderParameters(header, argumentInfo.HTTP, queryParams) case rest.InQuery: q := endpoint.Query() for _, qp := range queryParams { - evalQueryParameterURL(&q, argumentKey, argumentInfo.HTTP.EncodingObject, qp.Keys(), qp.Values()) + contenttype.EvalQueryParameterURL(&q, argumentKey, argumentInfo.HTTP.EncodingObject, qp.Keys(), qp.Values()) } - endpoint.RawQuery = encodeQueryValues(q, argumentInfo.HTTP.AllowReserved) + endpoint.RawQuery = contenttype.EncodeQueryValues(q, argumentInfo.HTTP.AllowReserved) case rest.InPath: defaultParam := queryParams.FindDefault() if defaultParam != nil { @@ -92,399 +86,3 @@ func (c *RequestBuilder) evalURLAndHeaderParameterBySchema(endpoint *url.URL, he return nil } - -func (c *RequestBuilder) encodeParameterValues(objectField *rest.ObjectField, reflectValue reflect.Value, fieldPaths []string) (ParameterItems, error) { - results := ParameterItems{} - - typeSchema := objectField.HTTP - reflectValue, nonNull := sdkUtils.UnwrapPointerFromReflectValue(reflectValue) - - switch ty := objectField.Type.Interface().(type) { - case *schema.NullableType: - if !nonNull { - return results, nil - } - - return c.encodeParameterValues(&rest.ObjectField{ - ObjectField: schema.ObjectField{ - Type: ty.UnderlyingType, - }, - HTTP: typeSchema, - }, reflectValue, fieldPaths) - case *schema.ArrayType: - if !nonNull { - return nil, fmt.Errorf("%s: %w", strings.Join(fieldPaths, ""), errArgumentRequired) - } - - elements, ok := reflectValue.Interface().([]any) - if !ok { - return nil, fmt.Errorf("%s: expected array, got <%s> %v", strings.Join(fieldPaths, ""), reflectValue.Kind(), reflectValue.Interface()) - } - - for i, elem := range elements { - outputs, err := c.encodeParameterValues(&rest.ObjectField{ - ObjectField: schema.ObjectField{ - Type: ty.ElementType, - }, - HTTP: typeSchema.Items, - }, reflect.ValueOf(elem), append(fieldPaths, "["+strconv.Itoa(i)+"]")) - if err != nil { - return nil, err - } - - for _, output := range outputs { - results.Add(append([]Key{NewIndexKey(i)}, output.Keys()...), output.Values()) - } - } - - return results, nil - case *schema.NamedType: - if !nonNull { - return nil, fmt.Errorf("%s: %w", strings.Join(fieldPaths, ""), errArgumentRequired) - } - iScalar, ok := c.Schema.ScalarTypes[ty.Name] - if ok { - return encodeScalarParameterReflectionValues(reflectValue, &iScalar, fieldPaths) - } - kind := reflectValue.Kind() - objectInfo, ok := c.Schema.ObjectTypes[ty.Name] - if !ok { - return nil, fmt.Errorf("%s: invalid type %s", strings.Join(fieldPaths, ""), ty.Name) - } - - switch kind { - case reflect.Map, reflect.Interface: - anyValue := reflectValue.Interface() - object, ok := anyValue.(map[string]any) - if !ok { - return nil, fmt.Errorf("%s: failed to evaluate object, got <%s> %v", strings.Join(fieldPaths, ""), kind, anyValue) - } - for key, fieldInfo := range objectInfo.Fields { - fieldVal := object[key] - output, err := c.encodeParameterValues(&fieldInfo, reflect.ValueOf(fieldVal), append(fieldPaths, "."+key)) - if err != nil { - return nil, err - } - - for _, pair := range output { - results.Add(append([]Key{NewKey(key)}, pair.Keys()...), pair.Values()) - } - } - case reflect.Struct: - reflectType := reflectValue.Type() - for fieldIndex := range reflectValue.NumField() { - fieldVal := reflectValue.Field(fieldIndex) - fieldType := reflectType.Field(fieldIndex) - fieldInfo, ok := objectInfo.Fields[fieldType.Name] - if !ok { - continue - } - - output, err := c.encodeParameterValues(&fieldInfo, fieldVal, append(fieldPaths, "."+fieldType.Name)) - if err != nil { - return nil, err - } - - for _, pair := range output { - results.Add(append([]Key{NewKey(fieldType.Name)}, pair.Keys()...), pair.Values()) - } - } - default: - return nil, fmt.Errorf("%s: failed to evaluate object, got %s", strings.Join(fieldPaths, ""), kind) - } - - return results, nil - } - - return nil, fmt.Errorf("%s: invalid type %v", strings.Join(fieldPaths, ""), objectField.Type) -} - -func encodeScalarParameterReflectionValues(reflectValue reflect.Value, scalar *schema.ScalarType, fieldPaths []string) (ParameterItems, error) { - switch sl := scalar.Representation.Interface().(type) { - case *schema.TypeRepresentationBoolean: - value, err := utils.DecodeBooleanReflection(reflectValue) - if err != nil { - return nil, fmt.Errorf("%s: %w", strings.Join(fieldPaths, ""), err) - } - - return []ParameterItem{ - NewParameterItem([]Key{}, []string{strconv.FormatBool(value)}), - }, nil - case *schema.TypeRepresentationString, *schema.TypeRepresentationBytes: - value, err := utils.DecodeStringReflection(reflectValue) - if err != nil { - return nil, fmt.Errorf("%s: %w", strings.Join(fieldPaths, ""), err) - } - - return []ParameterItem{NewParameterItem([]Key{}, []string{value})}, nil - case *schema.TypeRepresentationInteger, *schema.TypeRepresentationInt8, *schema.TypeRepresentationInt16, *schema.TypeRepresentationInt32, *schema.TypeRepresentationInt64, *schema.TypeRepresentationBigInteger: //nolint:all - value, err := utils.DecodeIntReflection[int64](reflectValue) - if err != nil { - return nil, fmt.Errorf("%s: %w", strings.Join(fieldPaths, ""), err) - } - - return []ParameterItem{ - NewParameterItem([]Key{}, []string{strconv.FormatInt(value, 10)}), - }, nil - case *schema.TypeRepresentationNumber, *schema.TypeRepresentationFloat32, *schema.TypeRepresentationFloat64, *schema.TypeRepresentationBigDecimal: //nolint:all - value, err := utils.DecodeFloatReflection[float64](reflectValue) - if err != nil { - return nil, fmt.Errorf("%s: %w", strings.Join(fieldPaths, ""), err) - } - - return []ParameterItem{ - NewParameterItem([]Key{}, []string{fmt.Sprint(value)}), - }, nil - case *schema.TypeRepresentationEnum: - value, err := utils.DecodeStringReflection(reflectValue) - if err != nil { - return nil, fmt.Errorf("%s: %w", strings.Join(fieldPaths, ""), err) - } - - if !slices.Contains(sl.OneOf, value) { - return nil, fmt.Errorf("%s: the value must be one of %v, got %s", strings.Join(fieldPaths, ""), sl.OneOf, value) - } - - return []ParameterItem{NewParameterItem([]Key{}, []string{value})}, nil - case *schema.TypeRepresentationDate: - value, err := utils.DecodeDateTimeReflection(reflectValue) - if err != nil { - return nil, fmt.Errorf("%s: %w", strings.Join(fieldPaths, ""), err) - } - - return []ParameterItem{ - NewParameterItem([]Key{}, []string{value.Format(time.DateOnly)}), - }, nil - case *schema.TypeRepresentationTimestamp, *schema.TypeRepresentationTimestampTZ: - value, err := utils.DecodeDateTimeReflection(reflectValue) - if err != nil { - return nil, fmt.Errorf("%s: %w", strings.Join(fieldPaths, ""), err) - } - - return []ParameterItem{ - NewParameterItem([]Key{}, []string{value.Format(time.RFC3339)}), - }, nil - case *schema.TypeRepresentationUUID: - rawValue, err := utils.DecodeStringReflection(reflectValue) - if err != nil { - return nil, fmt.Errorf("%s: %w", strings.Join(fieldPaths, ""), err) - } - - _, err = uuid.Parse(rawValue) - if err != nil { - return nil, fmt.Errorf("%s: %w", strings.Join(fieldPaths, ""), err) - } - - return []ParameterItem{NewParameterItem([]Key{}, []string{rawValue})}, nil - default: - return encodeParameterReflectionValues(reflectValue, fieldPaths) - } -} - -func encodeParameterReflectionValues(reflectValue reflect.Value, fieldPaths []string) (ParameterItems, error) { - reflectValue, ok := sdkUtils.UnwrapPointerFromReflectValue(reflectValue) - if !ok { - return ParameterItems{}, nil - } - - kind := reflectValue.Kind() - if result, err := contenttype.StringifySimpleScalar(reflectValue, kind); err == nil { - return []ParameterItem{ - NewParameterItem([]Key{}, []string{result}), - }, nil - } - - switch kind { - case reflect.Slice, reflect.Array: - return encodeParameterReflectionSlice(reflectValue, fieldPaths) - case reflect.Map, reflect.Interface: - return encodeParameterReflectionMap(reflectValue, fieldPaths) - case reflect.Struct: - return encodeParameterReflectionStruct(reflectValue, fieldPaths) - default: - return nil, fmt.Errorf("%s: failed to encode parameter, got %s", strings.Join(fieldPaths, ""), kind) - } -} - -func encodeParameterReflectionSlice(reflectValue reflect.Value, fieldPaths []string) (ParameterItems, error) { - results := ParameterItems{} - valueLen := reflectValue.Len() - for i := range valueLen { - elem := reflectValue.Index(i) - outputs, err := encodeParameterReflectionValues(elem, append(fieldPaths, fmt.Sprintf("[%d]", i))) - if err != nil { - return nil, err - } - - for _, output := range outputs { - results.Add(append([]Key{NewIndexKey(i)}, output.Keys()...), output.Values()) - } - } - - return results, nil -} - -func encodeParameterReflectionStruct(reflectValue reflect.Value, fieldPaths []string) (ParameterItems, error) { - results := ParameterItems{} - reflectType := reflectValue.Type() - for fieldIndex := range reflectValue.NumField() { - fieldVal := reflectValue.Field(fieldIndex) - fieldType := reflectType.Field(fieldIndex) - output, err := encodeParameterReflectionValues(fieldVal, append(fieldPaths, "."+fieldType.Name)) - if err != nil { - return nil, err - } - - for _, pair := range output { - results.Add(append([]Key{NewKey(fieldType.Name)}, pair.Keys()...), pair.Values()) - } - } - - return results, nil -} - -func encodeParameterReflectionMap(reflectValue reflect.Value, fieldPaths []string) (ParameterItems, error) { - results := ParameterItems{} - anyValue := reflectValue.Interface() - object, ok := anyValue.(map[string]any) - if !ok { - b, err := json.Marshal(anyValue) - if err != nil { - return nil, fmt.Errorf("%s: %w", strings.Join(fieldPaths, ""), err) - } - values := []string{strings.Trim(string(b), `"`)} - - return []ParameterItem{NewParameterItem([]Key{}, values)}, nil - } - - for key, fieldValue := range object { - output, err := encodeParameterReflectionValues(reflect.ValueOf(fieldValue), append(fieldPaths, "."+key)) - if err != nil { - return nil, err - } - - for _, pair := range output { - results.Add(append([]Key{NewKey(key)}, pair.Keys()...), pair.Values()) - } - } - - return results, nil -} - -func buildParamQueryKey(name string, encObject rest.EncodingObject, keys Keys, values []string) string { - resultKeys := []string{} - if name != "" { - resultKeys = append(resultKeys, name) - } - keysLength := len(keys) - // non-explode or explode form object does not require param name - // /users?role=admin&firstName=Alex - if (encObject.Explode != nil && !*encObject.Explode) || - (len(values) == 1 && encObject.Style == rest.EncodingStyleForm && (keysLength > 1 || (keysLength == 1 && !keys[0].IsEmpty()))) { - resultKeys = []string{} - } - - if keysLength > 0 { - if encObject.Style != rest.EncodingStyleDeepObject && keys[keysLength-1].IsEmpty() { - keys = keys[:keysLength-1] - } - - for i, key := range keys { - if len(resultKeys) == 0 { - resultKeys = append(resultKeys, key.String()) - - continue - } - if i == len(keys)-1 && key.Index() != nil { - // the last element of array in the deepObject style doesn't have index - resultKeys = append(resultKeys, "[]") - - continue - } - - resultKeys = append(resultKeys, "["+key.String()+"]") - } - } - - return strings.Join(resultKeys, "") -} - -func evalQueryParameterURL(q *url.Values, name string, encObject rest.EncodingObject, keys Keys, values []string) { - if len(values) == 0 { - return - } - paramKey := buildParamQueryKey(name, encObject, keys, values) - // encode explode queries, e.g /users?id=3&id=4&id=5 - if encObject.Explode == nil || *encObject.Explode { - for _, value := range values { - q.Add(paramKey, value) - } - - return - } - - switch encObject.Style { - case rest.EncodingStyleSpaceDelimited: - q.Add(name, strings.Join(values, " ")) - case rest.EncodingStylePipeDelimited: - q.Add(name, strings.Join(values, "|")) - // default style is form - default: - paramValues := values - if paramKey != "" { - paramValues = append([]string{paramKey}, paramValues...) - } - q.Add(name, strings.Join(paramValues, ",")) - } -} - -func encodeQueryValues(qValues url.Values, allowReserved bool) string { - if !allowReserved { - return qValues.Encode() - } - - var builder strings.Builder - index := 0 - for key, values := range qValues { - for i, value := range values { - if index > 0 || i > 0 { - builder.WriteRune('&') - } - builder.WriteString(key) - builder.WriteRune('=') - builder.WriteString(value) - } - index++ - } - - return builder.String() -} - -func setHeaderParameters(header *http.Header, param *rest.RequestParameter, queryParams ParameterItems) { - defaultParam := queryParams.FindDefault() - // the param is an array - if defaultParam != nil { - header.Set(param.Name, strings.Join(defaultParam.Values(), ",")) - - return - } - - if param.Explode != nil && *param.Explode { - var headerValues []string - for _, pair := range queryParams { - headerValues = append(headerValues, fmt.Sprintf("%s=%s", pair.Keys().String(), strings.Join(pair.Values(), ","))) - } - header.Set(param.Name, strings.Join(headerValues, ",")) - - return - } - - var headerValues []string - for _, pair := range queryParams { - pairKey := pair.Keys().String() - for _, v := range pair.Values() { - headerValues = append(headerValues, pairKey, v) - } - } - header.Set(param.Name, strings.Join(headerValues, ",")) -} diff --git a/connector/internal/request_parameter_test.go b/connector/internal/request_parameter_test.go index 1691734..e298ede 100644 --- a/connector/internal/request_parameter_test.go +++ b/connector/internal/request_parameter_test.go @@ -3,248 +3,13 @@ package internal import ( "encoding/json" "net/url" + "os" "testing" rest "github.com/hasura/ndc-http/ndc-http-schema/schema" - "github.com/hasura/ndc-sdk-go/utils" "gotest.tools/v3/assert" ) -func TestEvalQueryParameterURL(t *testing.T) { - testCases := []struct { - name string - param *rest.RequestParameter - keys []Key - values []string - expected string - }{ - { - name: "empty", - param: &rest.RequestParameter{}, - keys: []Key{NewKey("")}, - values: []string{}, - expected: "", - }, - { - name: "form_explode_single", - param: &rest.RequestParameter{ - Name: "id", - EncodingObject: rest.EncodingObject{ - Explode: utils.ToPtr(true), - Style: rest.EncodingStyleForm, - }, - }, - keys: []Key{}, - values: []string{"3"}, - expected: "id=3", - }, - { - name: "form_single", - param: &rest.RequestParameter{ - Name: "id", - EncodingObject: rest.EncodingObject{ - Explode: utils.ToPtr(false), - Style: rest.EncodingStyleForm, - }, - }, - keys: []Key{NewKey("")}, - values: []string{"3"}, - expected: "id=3", - }, - { - name: "form_explode_multiple", - param: &rest.RequestParameter{ - Name: "id", - EncodingObject: rest.EncodingObject{ - Explode: utils.ToPtr(true), - Style: rest.EncodingStyleForm, - }, - }, - keys: []Key{NewKey("")}, - values: []string{"3", "4", "5"}, - expected: "id=3&id=4&id=5", - }, - { - name: "spaceDelimited_multiple", - param: &rest.RequestParameter{ - Name: "id", - EncodingObject: rest.EncodingObject{ - Explode: utils.ToPtr(false), - Style: rest.EncodingStyleSpaceDelimited, - }, - }, - keys: []Key{NewKey("")}, - values: []string{"3", "4", "5"}, - expected: "id=3 4 5", - }, - { - name: "spaceDelimited_explode_multiple", - param: &rest.RequestParameter{ - Name: "id", - EncodingObject: rest.EncodingObject{ - Explode: utils.ToPtr(true), - Style: rest.EncodingStyleSpaceDelimited, - }, - }, - keys: []Key{NewKey("")}, - values: []string{"3", "4", "5"}, - expected: "id=3&id=4&id=5", - }, - - { - name: "pipeDelimited_multiple", - param: &rest.RequestParameter{ - Name: "id", - EncodingObject: rest.EncodingObject{ - Explode: utils.ToPtr(false), - Style: rest.EncodingStylePipeDelimited, - }, - }, - keys: []Key{NewKey("")}, - values: []string{"3", "4", "5"}, - expected: "id=3|4|5", - }, - { - name: "pipeDelimited_explode_multiple", - param: &rest.RequestParameter{ - Name: "id", - EncodingObject: rest.EncodingObject{ - Explode: utils.ToPtr(true), - Style: rest.EncodingStylePipeDelimited, - }, - }, - keys: []Key{NewKey("")}, - values: []string{"3", "4", "5"}, - expected: "id=3&id=4&id=5", - }, - { - name: "deepObject_explode_multiple", - param: &rest.RequestParameter{ - Name: "id", - EncodingObject: rest.EncodingObject{ - Explode: utils.ToPtr(true), - Style: rest.EncodingStyleDeepObject, - }, - }, - keys: []Key{NewKey("")}, - values: []string{"3", "4", "5"}, - expected: "id[]=3&id[]=4&id[]=5", - }, - { - name: "form_object", - param: &rest.RequestParameter{ - Name: "id", - EncodingObject: rest.EncodingObject{ - Explode: utils.ToPtr(false), - Style: rest.EncodingStyleForm, - }, - }, - keys: []Key{NewKey("role")}, - values: []string{"admin"}, - expected: "id=role,admin", - }, - { - name: "form_explode_object", - param: &rest.RequestParameter{ - Name: "id", - EncodingObject: rest.EncodingObject{ - Explode: utils.ToPtr(true), - Style: rest.EncodingStyleForm, - }, - }, - keys: []Key{NewKey("role")}, - values: []string{"admin"}, - expected: "role=admin", - }, - { - name: "deepObject_explode_object", - param: &rest.RequestParameter{ - Name: "id", - EncodingObject: rest.EncodingObject{ - Explode: utils.ToPtr(true), - Style: rest.EncodingStyleDeepObject, - }, - }, - keys: []Key{NewKey("role")}, - values: []string{"admin"}, - expected: "id[role]=admin", - }, - { - name: "form_array_object", - param: &rest.RequestParameter{ - Name: "id", - EncodingObject: rest.EncodingObject{ - Explode: utils.ToPtr(false), - Style: rest.EncodingStyleForm, - }, - }, - keys: []Key{NewKey("role"), NewKey(""), NewKey("user"), NewKey("")}, - values: []string{"admin"}, - expected: "id=role[][user],admin", - }, - { - name: "form_explode_array_object", - param: &rest.RequestParameter{ - Name: "id", - EncodingObject: rest.EncodingObject{ - Explode: utils.ToPtr(true), - Style: rest.EncodingStyleForm, - }, - }, - keys: []Key{NewKey("role"), NewKey(""), NewKey("user"), NewKey("")}, - values: []string{"admin"}, - expected: "role[][user]=admin", - }, - { - name: "form_explode_array_object_multiple", - param: &rest.RequestParameter{ - Name: "id", - EncodingObject: rest.EncodingObject{ - Explode: utils.ToPtr(true), - Style: rest.EncodingStyleForm, - }, - }, - keys: []Key{NewKey("role"), NewKey(""), NewKey("user"), NewKey("")}, - values: []string{"admin", "anonymous"}, - expected: "id[role][][user]=admin&id[role][][user]=anonymous", - }, - { - name: "deepObject_explode_array_object", - param: &rest.RequestParameter{ - Name: "id", - EncodingObject: rest.EncodingObject{ - Explode: utils.ToPtr(true), - Style: rest.EncodingStyleDeepObject, - }, - }, - keys: []Key{NewKey("role"), NewKey(""), NewKey("user"), NewKey("")}, - values: []string{"admin"}, - expected: "id[role][][user][]=admin", - }, - { - name: "deepObject_explode_array_object_multiple", - param: &rest.RequestParameter{ - Name: "id", - EncodingObject: rest.EncodingObject{ - Explode: utils.ToPtr(true), - Style: rest.EncodingStyleDeepObject, - }, - }, - keys: []Key{NewKey("role"), NewKey(""), NewKey("user"), NewKey("")}, - values: []string{"admin", "anonymous"}, - expected: "id[role][][user][]=admin&id[role][][user][]=anonymous", - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - qValues := make(url.Values) - evalQueryParameterURL(&qValues, tc.param.Name, tc.param.EncodingObject, tc.keys, tc.values) - assert.Equal(t, tc.expected, encodeQueryValues(qValues, true)) - }) - } -} - func TestEvalURLAndHeaderParameters(t *testing.T) { testCases := []struct { name string @@ -311,3 +76,12 @@ func TestEvalURLAndHeaderParameters(t *testing.T) { }) } } + +func createMockSchema(t *testing.T) *rest.NDCHttpSchema { + var ndcSchema rest.NDCHttpSchema + rawSchemaBytes, err := os.ReadFile("../../ndc-http-schema/openapi/testdata/petstore3/expected.json") + assert.NilError(t, err) + assert.NilError(t, json.Unmarshal(rawSchemaBytes, &ndcSchema)) + + return &ndcSchema +} diff --git a/connector/internal/types.go b/connector/internal/types.go index 4661925..4b31024 100644 --- a/connector/internal/types.go +++ b/connector/internal/types.go @@ -17,9 +17,7 @@ const ( ) var ( - errArgumentRequired = errors.New("argument is required") - errRequestBodyRequired = errors.New("request body is required") - errRequestBodyTypeRequired = errors.New("failed to decode request body, empty body type") + errRequestBodyRequired = errors.New("request body is required") ) var defaultRetryHTTPStatus = []int{429, 500, 502, 503} diff --git a/connector/internal/upstream.go b/connector/internal/upstream.go index 670f825..b2db3da 100644 --- a/connector/internal/upstream.go +++ b/connector/internal/upstream.go @@ -386,7 +386,7 @@ func (sm *UpstreamManager) registerSecurityCredentials(ctx context.Context, http credentials[key] = cred if headerForwardRequired && (!sm.config.ForwardHeaders.Enabled || sm.config.ForwardHeaders.ArgumentField == nil || *sm.config.ForwardHeaders.ArgumentField == "") { - logger.Warn("%s: the security scheme needs header forwarding enabled with argumentField set", slog.String("scheme", key)) + logger.Warn("the security scheme needs header forwarding enabled with argumentField set", slog.String("scheme", key)) } }