From f1293b7fea3fffa4c5127e48302b1ea0b7522e7b Mon Sep 17 00:00:00 2001 From: Toan Nguyen Date: Mon, 16 Dec 2024 00:55:02 +0700 Subject: [PATCH] add sendHttpRequest mutation --- Dockerfile | 7 +- connector/connector.go | 14 +- connector/internal/client.go | 26 +- connector/internal/contenttype/multipart.go | 51 ++++ connector/internal/contenttype/url_encode.go | 34 ++- .../internal/contenttype/url_encode_test.go | 2 +- connector/internal/contenttype/xml_decode.go | 4 + connector/internal/contenttype/xml_encode.go | 123 +++++++- .../internal/contenttype/xml_encode_test.go | 72 +++++ connector/internal/request_builder.go | 7 +- connector/internal/request_raw.go | 277 ++++++++++++++++++ connector/internal/schema.go | 101 +++++++ connector/internal/upstream.go | 2 + connector/mutation.go | 19 +- connector/mutation_test.go | 16 + connector/query.go | 15 +- connector/schema.go | 11 +- .../cenyzlota-2013-01-02/expected.json | 13 + .../cenyzlota-2013-01-02/request.json | 23 ++ .../raw/mutation/cenyzlota/expected.json | 103 +++++++ .../raw/mutation/cenyzlota/request.json | 23 ++ .../raw/mutation/delete/expected.json | 8 + .../testdata/raw/mutation/delete/request.json | 13 + .../testdata/raw/mutation/image/expected.json | 8 + .../testdata/raw/mutation/image/request.json | 13 + .../testdata/raw/mutation/post/expected.json | 13 + .../testdata/raw/mutation/post/request.json | 19 ++ ndc-http-schema/configuration/schema.go | 45 ++- ndc-http-schema/configuration/types.go | 7 - ndc-http-schema/schema/schema.go | 21 ++ ndc-http-schema/schema/setting.go | 10 +- ndc-http-schema/version/version.go | 33 ++- server/main.go | 2 + tests/engine/app/metadata/myapi-types.hml | 76 +++++ tests/engine/app/metadata/myapi.hml | 90 ++++++ tests/engine/app/metadata/sendHttpRequest.hml | 79 +++++ 36 files changed, 1299 insertions(+), 81 deletions(-) create mode 100644 connector/internal/request_raw.go create mode 100644 connector/internal/schema.go create mode 100644 connector/mutation_test.go create mode 100644 connector/testdata/raw/mutation/cenyzlota-2013-01-02/expected.json create mode 100644 connector/testdata/raw/mutation/cenyzlota-2013-01-02/request.json create mode 100644 connector/testdata/raw/mutation/cenyzlota/expected.json create mode 100644 connector/testdata/raw/mutation/cenyzlota/request.json create mode 100644 connector/testdata/raw/mutation/delete/expected.json create mode 100644 connector/testdata/raw/mutation/delete/request.json create mode 100644 connector/testdata/raw/mutation/image/expected.json create mode 100644 connector/testdata/raw/mutation/image/request.json create mode 100644 connector/testdata/raw/mutation/post/expected.json create mode 100644 connector/testdata/raw/mutation/post/request.json create mode 100644 tests/engine/app/metadata/sendHttpRequest.hml diff --git a/Dockerfile b/Dockerfile index b0c70b7..3332e0b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,11 +2,16 @@ FROM golang:1.23 AS builder WORKDIR /app + +ARG VERSION COPY ndc-http-schema ./ndc-http-schema COPY go.mod go.sum go.work ./ RUN go mod download COPY . . -RUN CGO_ENABLED=0 go build -v -o ndc-cli ./server + +RUN CGO_ENABLED=0 go build \ + -ldflags "-X github.com/hasura/ndc-http/ndc-http-schema/version.BuildVersion=${VERSION}" \ + -v -o ndc-cli ./server # stage 2: production image FROM gcr.io/distroless/static-debian12:nonroot diff --git a/connector/connector.go b/connector/connector.go index 8383897..6f8b8e2 100644 --- a/connector/connector.go +++ b/connector/connector.go @@ -9,18 +9,20 @@ import ( "github.com/hasura/ndc-http/connector/internal" "github.com/hasura/ndc-http/ndc-http-schema/configuration" + rest "github.com/hasura/ndc-http/ndc-http-schema/schema" "github.com/hasura/ndc-sdk-go/connector" "github.com/hasura/ndc-sdk-go/schema" ) // HTTPConnector implements the SDK interface of NDC specification type HTTPConnector struct { - config *configuration.Configuration - metadata internal.MetadataCollection - capabilities *schema.RawCapabilitiesResponse - rawSchema *schema.RawSchemaResponse - httpClient *http.Client - upstreams *internal.UpstreamManager + config *configuration.Configuration + metadata internal.MetadataCollection + capabilities *schema.RawCapabilitiesResponse + rawSchema *schema.RawSchemaResponse + httpClient *http.Client + upstreams *internal.UpstreamManager + procSendHttpRequest rest.OperationInfo } // NewHTTPConnector creates a HTTP connector instance diff --git a/connector/internal/client.go b/connector/internal/client.go index c084a4e..1cb95eb 100644 --- a/connector/internal/client.go +++ b/connector/internal/client.go @@ -292,6 +292,12 @@ func (client *HTTPClient) doRequest(ctx context.Context, request *RetryableReque attribute.String("network.protocol.name", "http"), ) + var namespace string + if client.requests.Schema != nil { + namespace = client.requests.Schema.Name + span.SetAttributes(attribute.String("db.namespace", namespace)) + } + if request.ContentLength > 0 { span.SetAttributes(attribute.Int64("http.request.body.size", request.ContentLength)) } @@ -301,8 +307,7 @@ func (client *HTTPClient) doRequest(ctx context.Context, request *RetryableReque setHeaderAttributes(span, "http.request.header.", request.Headers) client.propagator.Inject(ctx, propagation.HeaderCarrier(request.Headers)) - - resp, cancel, err := client.manager.ExecuteRequest(ctx, request, client.requests.Schema.Name) + resp, cancel, err := client.manager.ExecuteRequest(ctx, request, namespace) if err != nil { span.SetStatus(codes.Error, "error happened when executing the request") span.RecordError(err) @@ -371,7 +376,7 @@ func (client *HTTPClient) evalHTTPResponse(ctx context.Context, span trace.Span, var result any switch { - case strings.HasPrefix(contentType, "text/"): + case strings.HasPrefix(contentType, "text/") || strings.HasPrefix(contentType, "image/svg"): respBody, err := io.ReadAll(resp.Body) if err != nil { return nil, nil, schema.NewConnectorError(http.StatusInternalServerError, err.Error(), nil) @@ -412,13 +417,18 @@ func (client *HTTPClient) evalHTTPResponse(ctx context.Context, span trace.Span, } } - responseType, extractErr := client.extractResultType(resultType) - if extractErr != nil { - return nil, nil, extractErr + var err error + if client.requests.Schema == nil || client.requests.Schema.NDCHttpSchema == nil { + err = json.NewDecoder(resp.Body).Decode(&result) + } else { + responseType, extractErr := client.extractResultType(resultType) + if extractErr != nil { + return nil, nil, extractErr + } + + result, err = contenttype.NewJSONDecoder(client.requests.Schema.NDCHttpSchema).Decode(resp.Body, responseType) } - var err error - result, err = contenttype.NewJSONDecoder(client.requests.Schema.NDCHttpSchema).Decode(resp.Body, responseType) if err != nil { return nil, nil, schema.NewConnectorError(http.StatusInternalServerError, err.Error(), nil) } diff --git a/connector/internal/contenttype/multipart.go b/connector/internal/contenttype/multipart.go index 891c8de..43484b0 100644 --- a/connector/internal/contenttype/multipart.go +++ b/connector/internal/contenttype/multipart.go @@ -57,6 +57,7 @@ func (mfb *MultipartFormEncoder) evalMultipartForm(w *MultipartWriter, bodyInfo if !ok { return nil } + switch bodyType := bodyInfo.Type.Interface().(type) { case *schema.NullableType: return mfb.evalMultipartForm(w, &rest.ArgumentInfo{ @@ -73,6 +74,7 @@ func (mfb *MultipartFormEncoder) evalMultipartForm(w *MultipartWriter, bodyInfo if !ok { break } + kind := bodyData.Kind() switch kind { case reflect.Map, reflect.Interface: @@ -276,3 +278,52 @@ func (mfb *MultipartFormEncoder) evalEncodingHeaders(encHeaders map[string]rest. return results, nil } + +// EncodeArbitrary encodes the unknown data to multipart/form. +func (c *MultipartFormEncoder) EncodeArbitrary(bodyData any) (*bytes.Reader, string, error) { + buffer := new(bytes.Buffer) + writer := NewMultipartWriter(buffer) + + reflectValue, ok := utils.UnwrapPointerFromReflectValue(reflect.ValueOf(bodyData)) + if ok { + valueMap, ok := reflectValue.Interface().(map[string]any) + if !ok { + return nil, "", fmt.Errorf("invalid body for multipart/form, expected object, got: %s", reflectValue.Kind()) + } + + for key, value := range valueMap { + if err := c.evalFormDataReflection(writer, key, reflect.ValueOf(value)); err != nil { + return nil, "", fmt.Errorf("invalid body for multipart/form, %s: %w", key, err) + } + } + } + + if err := writer.Close(); err != nil { + return nil, "", err + } + + reader := bytes.NewReader(buffer.Bytes()) + buffer.Reset() + + return reader, writer.FormDataContentType(), nil +} + +func (c *MultipartFormEncoder) evalFormDataReflection(w *MultipartWriter, key string, reflectValue reflect.Value) error { + reflectValue, ok := utils.UnwrapPointerFromReflectValue(reflectValue) + if !ok { + return nil + } + + kind := reflectValue.Kind() + switch kind { + case reflect.Map, reflect.Struct, reflect.Array, reflect.Slice: + return w.WriteJSON(key, reflectValue.Interface(), http.Header{}) + default: + value, err := StringifySimpleScalar(reflectValue, kind) + if err != nil { + return err + } + + return w.WriteField(key, value, http.Header{}) + } +} diff --git a/connector/internal/contenttype/url_encode.go b/connector/internal/contenttype/url_encode.go index 7f1ba25..31ff5d4 100644 --- a/connector/internal/contenttype/url_encode.go +++ b/connector/internal/contenttype/url_encode.go @@ -33,7 +33,7 @@ func NewURLParameterEncoder(schema *rest.NDCHttpSchema, contentType string) *URL } } -func (c *URLParameterEncoder) Encode(bodyInfo *rest.ArgumentInfo, bodyData any) (io.ReadSeeker, error) { +func (c *URLParameterEncoder) Encode(bodyInfo *rest.ArgumentInfo, bodyData any) (io.ReadSeeker, int64, error) { queryParams, err := c.EncodeParameterValues(&rest.ObjectField{ ObjectField: schema.ObjectField{ Type: bodyInfo.Type, @@ -41,11 +41,11 @@ func (c *URLParameterEncoder) Encode(bodyInfo *rest.ArgumentInfo, bodyData any) HTTP: bodyInfo.HTTP.Schema, }, reflect.ValueOf(bodyData), []string{"body"}) if err != nil { - return nil, err + return nil, 0, err } if len(queryParams) == 0 { - return nil, nil + return nil, 0, nil } q := url.Values{} for _, qp := range queryParams { @@ -53,8 +53,31 @@ func (c *URLParameterEncoder) Encode(bodyInfo *rest.ArgumentInfo, bodyData any) EvalQueryParameterURL(&q, "", bodyInfo.HTTP.EncodingObject, keys, qp.Values()) } rawQuery := EncodeQueryValues(q, true) + result := bytes.NewReader([]byte(rawQuery)) - return bytes.NewReader([]byte(rawQuery)), nil + return result, result.Size(), nil +} + +// Encode marshals the arbitrary body to xml bytes. +func (c *URLParameterEncoder) EncodeArbitrary(bodyData any) (io.ReadSeeker, int64, error) { + queryParams, err := c.encodeParameterReflectionValues(reflect.ValueOf(bodyData), []string{"body"}) + if err != nil { + return nil, 0, err + } + + if len(queryParams) == 0 { + return nil, 0, nil + } + q := url.Values{} + encObject := rest.EncodingObject{} + for _, qp := range queryParams { + keys := qp.Keys() + EvalQueryParameterURL(&q, "", encObject, keys, qp.Values()) + } + rawQuery := EncodeQueryValues(q, true) + result := bytes.NewReader([]byte(rawQuery)) + + return result, result.Size(), nil } func (c *URLParameterEncoder) EncodeParameterValues(objectField *rest.ObjectField, reflectValue reflect.Value, fieldPaths []string) (ParameterItems, error) { @@ -382,6 +405,7 @@ func buildParamQueryKey(name string, encObject rest.EncodingObject, keys Keys, v return strings.Join(resultKeys, "") } +// EvalQueryParameterURL evaluate the query parameter URL func EvalQueryParameterURL(q *url.Values, name string, encObject rest.EncodingObject, keys Keys, values []string) { if len(values) == 0 { return @@ -411,6 +435,7 @@ func EvalQueryParameterURL(q *url.Values, name string, encObject rest.EncodingOb } } +// EncodeQueryValues encode query values to string. func EncodeQueryValues(qValues url.Values, allowReserved bool) string { if !allowReserved { return qValues.Encode() @@ -433,6 +458,7 @@ func EncodeQueryValues(qValues url.Values, allowReserved bool) string { return builder.String() } +// SetHeaderParameters set parameters to request headers func SetHeaderParameters(header *http.Header, param *rest.RequestParameter, queryParams ParameterItems) { defaultParam := queryParams.FindDefault() // the param is an array diff --git a/connector/internal/contenttype/url_encode_test.go b/connector/internal/contenttype/url_encode_test.go index 13db5d9..89bc82d 100644 --- a/connector/internal/contenttype/url_encode_test.go +++ b/connector/internal/contenttype/url_encode_test.go @@ -692,7 +692,7 @@ func TestCreateFormURLEncoded(t *testing.T) { assert.NilError(t, json.Unmarshal([]byte(tc.RawArguments), &arguments)) argumentInfo := info.Arguments["body"] builder := NewURLParameterEncoder(ndcSchema, rest.ContentTypeFormURLEncoded) - buf, err := builder.Encode(&argumentInfo, arguments["body"]) + buf, _, err := builder.Encode(&argumentInfo, arguments["body"]) assert.NilError(t, err) result, err := io.ReadAll(buf) assert.NilError(t, err) diff --git a/connector/internal/contenttype/xml_decode.go b/connector/internal/contenttype/xml_decode.go index 7ceda71..3c13948 100644 --- a/connector/internal/contenttype/xml_decode.go +++ b/connector/internal/contenttype/xml_decode.go @@ -44,6 +44,10 @@ func (c *XMLDecoder) Decode(r io.Reader, resultType schema.Type) (any, error) { return nil, fmt.Errorf("failed to decode the xml result: %w", err) } + if c.schema == nil { + return decodeArbitraryXMLBlock(xmlTree), nil + } + result, err := c.evalXMLField(xmlTree, "", rest.ObjectField{ ObjectField: schema.ObjectField{ Type: resultType, diff --git a/connector/internal/contenttype/xml_encode.go b/connector/internal/contenttype/xml_encode.go index f10f769..3864498 100644 --- a/connector/internal/contenttype/xml_encode.go +++ b/connector/internal/contenttype/xml_encode.go @@ -49,6 +49,23 @@ func (c *XMLEncoder) Encode(bodyInfo *rest.ArgumentInfo, bodyData any) ([]byte, return append([]byte(xml.Header), buf.Bytes()...), nil } +// Encode marshals the arbitrary body to xml bytes. +func (c *XMLEncoder) EncodeArbitrary(bodyData any) ([]byte, error) { + var buf bytes.Buffer + enc := xml.NewEncoder(&buf) + + err := c.encodeSimpleScalar(enc, "xml", reflect.ValueOf(bodyData), nil, []string{}) + if err != nil { + return nil, err + } + + if err := enc.Flush(); err != nil { + return nil, err + } + + return append([]byte(xml.Header), buf.Bytes()...), nil +} + func (c *XMLEncoder) evalXMLField(enc *xml.Encoder, name string, field rest.ObjectField, value any, fieldPaths []string) error { rawType, err := field.Type.InterfaceT() var innerValue reflect.Value @@ -134,7 +151,7 @@ func (c *XMLEncoder) evalXMLField(enc *xml.Encoder, name string, field rest.Obje } if _, ok := c.schema.ScalarTypes[t.Name]; ok { - if err := c.encodeSimpleScalar(enc, xmlName, reflect.ValueOf(value), attributes); err != nil { + if err := c.encodeSimpleScalar(enc, xmlName, reflect.ValueOf(value), attributes, fieldPaths); err != nil { return fmt.Errorf("%s: %w", strings.Join(fieldPaths, "."), err) } @@ -301,25 +318,113 @@ func (c *XMLEncoder) encodeXMLText(schemaType schema.Type, value reflect.Value, } } -func (c *XMLEncoder) encodeSimpleScalar(enc *xml.Encoder, name string, value reflect.Value, attributes []xml.Attr) error { - str, err := StringifySimpleScalar(value, value.Kind()) +func (c *XMLEncoder) encodeSimpleScalar(enc *xml.Encoder, name string, reflectValue reflect.Value, attributes []xml.Attr, fieldPaths []string) error { + reflectValue, ok := utils.UnwrapPointerFromReflectValue(reflectValue) + if !ok { + return nil + } + + kind := reflectValue.Kind() + switch kind { + case reflect.Slice, reflect.Array: + if len(fieldPaths) == 0 { + err := enc.EncodeToken(xml.StartElement{ + Name: xml.Name{Local: name}, + }) + if err != nil { + return err + } + } + + for i := 0; i < reflectValue.Len(); i++ { + item := reflectValue.Index(i) + if err := c.encodeSimpleScalar(enc, name, item, attributes, append(fieldPaths, strconv.Itoa(i))); err != nil { + return err + } + } + + if len(fieldPaths) == 0 { + err := enc.EncodeToken(xml.EndElement{ + Name: xml.Name{Local: name}, + }) + if err != nil { + return err + } + } + + return nil + case reflect.Map: + ri := reflectValue.Interface() + valueMap, ok := ri.(map[string]any) + if !ok { + return fmt.Errorf("%s: expected map[string]any, got: %v", strings.Join(fieldPaths, "."), ri) + } + + return c.encodeScalarMap(enc, name, valueMap, attributes, fieldPaths) + case reflect.Interface: + ri := reflectValue.Interface() + valueMap, ok := ri.(map[string]any) + if ok { + return c.encodeScalarMap(enc, name, valueMap, attributes, fieldPaths) + } + + return c.encodeScalarString(enc, name, reflectValue, kind, attributes, fieldPaths) + default: + return c.encodeScalarString(enc, name, reflectValue, kind, attributes, fieldPaths) + } +} + +func (c *XMLEncoder) encodeScalarMap(enc *xml.Encoder, name string, valueMap map[string]any, attributes []xml.Attr, fieldPaths []string) error { + err := enc.EncodeToken(xml.StartElement{ + Name: xml.Name{Local: name}, + Attr: attributes, + }) if err != nil { - return err + return fmt.Errorf("%s: %w", strings.Join(fieldPaths, "."), err) + } + + keys := utils.GetSortedKeys(valueMap) + for _, key := range keys { + item := valueMap[key] + if err := c.encodeSimpleScalar(enc, key, reflect.ValueOf(item), nil, append(fieldPaths, key)); err != nil { + return err + } + } + + err = enc.EncodeToken(xml.EndElement{ + Name: xml.Name{Local: name}, + }) + if err != nil { + return fmt.Errorf("%s: %w", strings.Join(fieldPaths, "."), err) + } + + return nil +} + +func (c *XMLEncoder) encodeScalarString(enc *xml.Encoder, name string, reflectValue reflect.Value, kind reflect.Kind, attributes []xml.Attr, fieldPaths []string) error { + str, err := StringifySimpleScalar(reflectValue, kind) + if err != nil { + return fmt.Errorf("%s: %w", strings.Join(fieldPaths, "."), err) } err = enc.EncodeToken(xml.StartElement{ - Name: xml.Name{Space: "", Local: name}, + Name: xml.Name{Local: name}, Attr: attributes, }) if err != nil { - return err + return fmt.Errorf("%s: %w", strings.Join(fieldPaths, "."), err) } if err := enc.EncodeToken(xml.CharData(str)); err != nil { - return err + return fmt.Errorf("%s: %w", strings.Join(fieldPaths, "."), err) } - return enc.EncodeToken(xml.EndElement{ - Name: xml.Name{Space: "", Local: name}, + err = enc.EncodeToken(xml.EndElement{ + Name: xml.Name{Local: name}, }) + if err != nil { + return fmt.Errorf("%s: %w", strings.Join(fieldPaths, "."), err) + } + + return nil } diff --git a/connector/internal/contenttype/xml_encode_test.go b/connector/internal/contenttype/xml_encode_test.go index 7434471..9d5289a 100644 --- a/connector/internal/contenttype/xml_encode_test.go +++ b/connector/internal/contenttype/xml_encode_test.go @@ -121,3 +121,75 @@ func TestCreateXMLForm(t *testing.T) { }) } } + +func TestCreateArbitraryXMLForm(t *testing.T) { + testCases := []struct { + Name string + Body map[string]any + + Expected string + }{ + { + Name: "putPetXml", + Body: map[string]any{ + "id": "10", + "name": "doggie", + "category": map[string]any{ + "id": "1", + "name": "Dogs", + }, + "photoUrls": "string", + "tags": map[string]any{ + "id": "0", + "name": "string", + }, + "status": "available", + }, + Expected: "\n1Dogs10doggiestringavailable0string", + }, + { + Name: "putCommentXml", + Body: map[string]any{ + "user": "Iggy", + "comment_count": "6", + "comment": []any{ + map[string]any{ + "who": "Iggy", + "when": "2021-10-15 13:28:22 UTC", + "id": "1", + "bsrequest": "115", + }, + map[string]any{ + "who": "Iggy", + "when": "2021-10-15 13:49:39 UTC", + "id": "2", + "project": "home:Admin", + }, + map[string]any{ + "who": "Iggy", + "when": "2021-10-15 13:54:38 UTC", + "id": "3", + "project": "home:Admin", + "package": "0ad", + }, + }, + }, + Expected: ` +11512021-10-15 13:28:22 UTCIggy2home:Admin2021-10-15 13:49:39 UTCIggy30adhome:Admin2021-10-15 13:54:38 UTCIggy6Iggy`, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + result, err := NewXMLEncoder(nil).EncodeArbitrary(tc.Body) + assert.NilError(t, err) + assert.Equal(t, tc.Expected, string(result)) + + dec := NewXMLDecoder(nil) + parsedResult, err := dec.Decode(bytes.NewBuffer([]byte(tc.Expected)), nil) + assert.NilError(t, err) + + assert.DeepEqual(t, tc.Body, parsedResult) + }) + } +} diff --git a/connector/internal/request_builder.go b/connector/internal/request_builder.go index 46dc852..df24acf 100644 --- a/connector/internal/request_builder.go +++ b/connector/internal/request_builder.go @@ -129,14 +129,15 @@ func (c *RequestBuilder) buildRequestBody(request *RetryableRequest, rawRequest return nil case contentType == rest.ContentTypeFormURLEncoded: - r, err := contenttype.NewURLParameterEncoder(c.Schema, rest.ContentTypeFormURLEncoded).Encode(&bodyInfo, bodyData) + r, size, err := contenttype.NewURLParameterEncoder(c.Schema, rest.ContentTypeFormURLEncoded).Encode(&bodyInfo, bodyData) if err != nil { return err } request.Body = r + request.ContentLength = size return nil - case contentType == rest.ContentTypeJSON || contentType == "": + case contentType == rest.ContentTypeJSON || contentType == "" || strings.HasSuffix(contentType, "+json"): var buf bytes.Buffer enc := json.NewEncoder(&buf) enc.SetEscapeHTML(false) @@ -149,7 +150,7 @@ func (c *RequestBuilder) buildRequestBody(request *RetryableRequest, rawRequest request.Body = bytes.NewReader(buf.Bytes()) return nil - case contentType == rest.ContentTypeXML: + case contentType == rest.ContentTypeXML || strings.HasSuffix(contentType, "+xml"): bodyBytes, err := contenttype.NewXMLEncoder(c.Schema).Encode(&bodyInfo, bodyData) if err != nil { return err diff --git a/connector/internal/request_raw.go b/connector/internal/request_raw.go new file mode 100644 index 0000000..2c38194 --- /dev/null +++ b/connector/internal/request_raw.go @@ -0,0 +1,277 @@ +package internal + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "slices" + "strings" + + "github.com/hasura/ndc-http/connector/internal/contenttype" + "github.com/hasura/ndc-http/ndc-http-schema/configuration" + rest "github.com/hasura/ndc-http/ndc-http-schema/schema" + restUtils "github.com/hasura/ndc-http/ndc-http-schema/utils" + "github.com/hasura/ndc-sdk-go/schema" +) + +// RawRequestBuilder represents a type to build a raw HTTP request +type RawRequestBuilder struct { + operation schema.MutationOperation + forwardHeaders configuration.ForwardHeadersSettings +} + +// NewRawRequestBuilder create a new RawRequestBuilder instance. +func NewRawRequestBuilder(operation schema.MutationOperation, forwardHeaders configuration.ForwardHeadersSettings) *RawRequestBuilder { + return &RawRequestBuilder{ + operation: operation, + forwardHeaders: forwardHeaders, + } +} + +func (rqe *RawRequestBuilder) Explain() (*schema.ExplainResponse, error) { + httpRequest, err := rqe.explain() + if err != nil { + return nil, err + } + + explainResp := &schema.ExplainResponse{ + Details: schema.ExplainResponseDetails{}, + } + + if httpRequest.Body != nil { + bodyBytes, err := io.ReadAll(httpRequest.Body) + if err != nil { + return nil, schema.InternalServerError("failed to read request body", map[string]any{ + "cause": err.Error(), + }) + } + httpRequest.Body = nil + explainResp.Details["body"] = string(bodyBytes) + } + + // mask sensitive forwarded headers if exists + for key, value := range httpRequest.Headers { + if IsSensitiveHeader(key) { + httpRequest.Headers.Set(key, restUtils.MaskString(value[0])) + } + } + + explainResp.Details["url"] = httpRequest.URL.String() + rawHeaders, err := json.Marshal(httpRequest.Headers) + if err != nil { + return nil, schema.InternalServerError("failed to encode headers", map[string]any{ + "cause": err.Error(), + }) + } + explainResp.Details["headers"] = string(rawHeaders) + + return explainResp, nil +} + +// Build evaluates and builds the raw request. +func (rqe *RawRequestBuilder) Build() (*RequestBuilderResults, error) { + httpRequest, err := rqe.explain() + if err != nil { + return nil, err + } + + return &RequestBuilderResults{ + Requests: []*RetryableRequest{httpRequest}, + HTTPOptions: &HTTPOptions{}, + Schema: &configuration.NDCHttpRuntimeSchema{}, + }, nil +} + +func (rqe *RawRequestBuilder) explain() (*RetryableRequest, error) { + request, err := rqe.decodeArguments() + if err != nil { + return nil, schema.UnprocessableContentError(err.Error(), nil) + } + + return request, nil +} + +func (rqe *RawRequestBuilder) decodeArguments() (*RetryableRequest, error) { + var rawArguments map[string]json.RawMessage + if err := json.Unmarshal(rqe.operation.Arguments, &rawArguments); err != nil { + return nil, err + } + + rawURL, ok := rawArguments["url"] + if !ok || len(rawURL) == 0 { + return nil, errors.New("url is required") + } + + var urlString string + if err := json.Unmarshal(rawURL, &urlString); err != nil { + return nil, fmt.Errorf("url: %w", err) + } + requestURL, err := rest.ParseHttpURL(urlString) + if err != nil { + return nil, fmt.Errorf("url: %w", err) + } + + rawMethod, ok := rawArguments["method"] + if !ok || len(rawMethod) == 0 { + return nil, errors.New("method is required") + } + + var method string + if err := json.Unmarshal(rawMethod, &method); err != nil { + return nil, fmt.Errorf("method: %w", err) + } + + if !slices.Contains(httpMethod_enums, method) { + return nil, fmt.Errorf("invalid http method, expected %v, got %s", httpMethod_enums, method) + } + + var timeout int + if rawTimeout, ok := rawArguments["timeout"]; ok { + if err := json.Unmarshal(rawTimeout, &timeout); err != nil { + return nil, fmt.Errorf("timeout: %w", err) + } + + if timeout < 0 { + return nil, errors.New("timeout must not be negative") + } + } + + var retryPolicy rest.RetryPolicy + if rawRetry, ok := rawArguments["retry"]; ok { + if err := json.Unmarshal(rawRetry, &retryPolicy); err != nil { + return nil, fmt.Errorf("retry: %w", err) + } + } + + headers := http.Header{} + contentType := rest.ContentTypeJSON + if rqe.forwardHeaders.Enabled && rqe.forwardHeaders.ArgumentField != nil && *rqe.forwardHeaders.ArgumentField != "" { + if rawHeaders, ok := rawArguments[*rqe.forwardHeaders.ArgumentField]; ok { + var fwHeaders map[string]string + if err := json.Unmarshal(rawHeaders, &fwHeaders); err != nil { + return nil, fmt.Errorf("%s: %w", *rqe.forwardHeaders.ArgumentField, err) + } + + for key, value := range fwHeaders { + headers.Set(key, value) + } + } + } + + if rawHeaders, ok := rawArguments["additionalHeaders"]; ok { + var additionalHeaders map[string]string + if err := json.Unmarshal(rawHeaders, &additionalHeaders); err != nil { + return nil, fmt.Errorf("additionalHeaders: %w", err) + } + + for key, value := range additionalHeaders { + if strings.ToLower(key) == "content-type" && value != "" { + contentType = value + } + headers.Set(key, value) + } + } + + request := &RetryableRequest{ + URL: *requestURL, + Headers: headers, + ContentType: contentType, + RawRequest: &rest.Request{ + URL: urlString, + Method: method, + }, + Runtime: rest.RuntimeSettings{ + Timeout: uint(timeout), + Retry: retryPolicy, + }, + } + + if method == "get" || method == "delete" { + return request, nil + } + + if rawBody, ok := rawArguments["body"]; ok && len(rawBody) > 0 { + reader, contentType, contentLength, err := rqe.evalRequestBody(rawBody, contentType) + if err != nil { + return nil, fmt.Errorf("body: %w", err) + } + request.ContentType = contentType + request.ContentLength = contentLength + request.Body = reader + } + + return request, nil +} + +func (rqe *RawRequestBuilder) evalRequestBody(rawBody json.RawMessage, contentType string) (io.ReadSeeker, string, int64, error) { + switch { + case contentType == rest.ContentTypeJSON || strings.HasSuffix(contentType, "+json"): + if !json.Valid(rawBody) { + return nil, "", 0, fmt.Errorf("invalid json body: %s", string(rawBody)) + } + + return bytes.NewReader(rawBody), contentType, int64(len(rawBody)), nil + case contentType == rest.ContentTypeXML || strings.HasSuffix(contentType, "+xml"): + var bodyData any + if err := json.Unmarshal(rawBody, &bodyData); err != nil { + return nil, "", 0, fmt.Errorf("invalid body: %w", err) + } + + if bodyStr, ok := bodyData.(string); ok { + return strings.NewReader(bodyStr), contentType, int64(len(bodyStr)), nil + } + + bodyBytes, err := contenttype.NewXMLEncoder(nil).EncodeArbitrary(bodyData) + if err != nil { + return nil, "", 0, err + } + + return bytes.NewReader(bodyBytes), contentType, int64(len(bodyBytes)), nil + case strings.HasPrefix(contentType, "text/"): + var bodyData string + if err := json.Unmarshal(rawBody, &bodyData); err != nil { + return nil, "", 0, fmt.Errorf("invalid body: %w", err) + } + + return strings.NewReader(bodyData), contentType, int64(len(bodyData)), nil + case strings.HasPrefix(contentType, "multipart/"): + var bodyData any + if err := json.Unmarshal(rawBody, &bodyData); err != nil { + return nil, "", 0, fmt.Errorf("invalid body: %w", err) + } + r, contentType, err := contenttype.NewMultipartFormEncoder(nil, nil, nil).EncodeArbitrary(bodyData) + if err != nil { + return nil, "", 0, err + } + + return r, contentType, r.Size(), nil + case contentType == rest.ContentTypeFormURLEncoded: + var bodyData any + if err := json.Unmarshal(rawBody, &bodyData); err != nil { + return nil, "", 0, fmt.Errorf("invalid body: %w", err) + } + + if bodyStr, ok := bodyData.(string); ok { + return strings.NewReader(bodyStr), contentType, int64(len(bodyStr)), nil + } + + r, size, err := contenttype.NewURLParameterEncoder(nil, rest.ContentTypeFormURLEncoded).EncodeArbitrary(bodyData) + + return r, contentType, size, err + default: + var bodyData string + if err := json.Unmarshal(rawBody, &bodyData); err != nil { + return nil, "", 0, fmt.Errorf("invalid body: %w", err) + } + dataURI, err := contenttype.DecodeDataURI(bodyData) + if err != nil { + return nil, "", 0, err + } + r := bytes.NewReader([]byte(dataURI.Data)) + + return r, contentType, r.Size(), nil + } +} diff --git a/connector/internal/schema.go b/connector/internal/schema.go new file mode 100644 index 0000000..6bde78e --- /dev/null +++ b/connector/internal/schema.go @@ -0,0 +1,101 @@ +package internal + +import ( + "github.com/hasura/ndc-http/ndc-http-schema/configuration" + rest "github.com/hasura/ndc-http/ndc-http-schema/schema" + restUtils "github.com/hasura/ndc-http/ndc-http-schema/utils" + "github.com/hasura/ndc-sdk-go/schema" + "github.com/hasura/ndc-sdk-go/utils" +) + +const ( + ProcedureSendHTTPRequest string = "sendHttpRequest" + ScalarRawHTTPMethod rest.ScalarName = "RawHttpMethod" + objectTypeRetryPolicy string = "RetryPolicy" +) + +var httpMethod_enums = []string{"get", "post", "put", "patch", "delete"} + +var defaultScalarTypes = map[rest.ScalarName]schema.ScalarType{ + rest.ScalarInt32: { + AggregateFunctions: schema.ScalarTypeAggregateFunctions{}, + ComparisonOperators: map[string]schema.ComparisonOperatorDefinition{}, + Representation: schema.NewTypeRepresentationInt32().Encode(), + }, + rest.ScalarString: { + AggregateFunctions: schema.ScalarTypeAggregateFunctions{}, + ComparisonOperators: map[string]schema.ComparisonOperatorDefinition{}, + Representation: schema.NewTypeRepresentationString().Encode(), + }, + rest.ScalarJSON: { + AggregateFunctions: schema.ScalarTypeAggregateFunctions{}, + ComparisonOperators: map[string]schema.ComparisonOperatorDefinition{}, + Representation: schema.NewTypeRepresentationJSON().Encode(), + }, + ScalarRawHTTPMethod: { + AggregateFunctions: schema.ScalarTypeAggregateFunctions{}, + ComparisonOperators: map[string]schema.ComparisonOperatorDefinition{}, + Representation: schema.NewTypeRepresentationEnum(httpMethod_enums).Encode(), + }, +} + +// ApplyDefaultConnectorSchema adds default connector schema to the existing schema. +func ApplyDefaultConnectorSchema(input *schema.SchemaResponse, forwardHeaderConfig configuration.ForwardHeadersSettings) (*schema.SchemaResponse, rest.OperationInfo) { + for _, scalarName := range utils.GetKeys(defaultScalarTypes) { + if _, ok := input.ScalarTypes[string(scalarName)]; ok { + continue + } + + input.ScalarTypes[string(scalarName)] = defaultScalarTypes[scalarName] + } + + input.ObjectTypes[objectTypeRetryPolicy] = rest.RetryPolicy{}.Schema() + procSendHttpRequest := schema.ProcedureInfo{ + Name: ProcedureSendHTTPRequest, + Description: utils.ToPtr("Send an HTTP request"), + Arguments: map[string]schema.ArgumentInfo{ + "url": { + Description: utils.ToPtr("Request URL"), + Type: schema.NewNamedType(string(rest.ScalarString)).Encode(), + }, + "method": { + Description: utils.ToPtr("Request method"), + Type: schema.NewNullableType(schema.NewNamedType(string(ScalarRawHTTPMethod))).Encode(), + }, + "additionalHeaders": { + Description: utils.ToPtr("Additional request headers"), + Type: schema.NewNullableType(schema.NewNamedType(string(rest.ScalarJSON))).Encode(), + }, + "body": { + Description: utils.ToPtr("Request body"), + Type: schema.NewNullableType(schema.NewNamedType(string(rest.ScalarJSON))).Encode(), + }, + "timeout": { + Description: utils.ToPtr("Request timeout in seconds"), + Type: schema.NewNullableType(schema.NewNamedType(string(rest.ScalarInt32))).Encode(), + }, + "retry": { + Description: utils.ToPtr("Retry policy"), + Type: schema.NewNullableType(schema.NewNamedType(objectTypeRetryPolicy)).Encode(), + }, + }, + ResultType: schema.NewNullableNamedType(string(rest.ScalarJSON)).Encode(), + } + + if forwardHeaderConfig.ArgumentField != nil && *forwardHeaderConfig.ArgumentField != "" { + procSendHttpRequest.Arguments[*forwardHeaderConfig.ArgumentField] = configuration.NewHeadersArgumentInfo().ArgumentInfo + } + + if forwardHeaderConfig.ResponseHeaders != nil { + objectTypeName := restUtils.ToPascalCase(procSendHttpRequest.Name) + "HeadersResponse" + input.ObjectTypes[objectTypeName] = configuration.NewHeaderForwardingResponseObjectType(procSendHttpRequest.ResultType, forwardHeaderConfig.ResponseHeaders).Schema() + + procSendHttpRequest.ResultType = schema.NewNamedType(objectTypeName).Encode() + } + + input.Procedures = append(input.Procedures, procSendHttpRequest) + + return input, rest.OperationInfo{ + ResultType: procSendHttpRequest.ResultType, + } +} diff --git a/connector/internal/upstream.go b/connector/internal/upstream.go index 54b8734..24ce52d 100644 --- a/connector/internal/upstream.go +++ b/connector/internal/upstream.go @@ -12,6 +12,7 @@ import ( "github.com/hasura/ndc-http/ndc-http-schema/configuration" "github.com/hasura/ndc-http/ndc-http-schema/schema" rest "github.com/hasura/ndc-http/ndc-http-schema/schema" + "github.com/hasura/ndc-http/ndc-http-schema/version" "github.com/hasura/ndc-sdk-go/connector" "github.com/hasura/ndc-sdk-go/utils" ) @@ -130,6 +131,7 @@ func (um *UpstreamManager) ExecuteRequest(ctx context.Context, request *Retryabl return nil, nil, err } + req.Header.Set("User-Agent", "ndc-http/"+version.BuildVersion) resp, err := httpClient.Do(req) if err != nil { cancel() diff --git a/connector/mutation.go b/connector/mutation.go index 7a58507..9de082d 100644 --- a/connector/mutation.go +++ b/connector/mutation.go @@ -24,13 +24,16 @@ func (c *HTTPConnector) Mutation(ctx context.Context, configuration *configurati // MutationExplain explains a mutation by creating an execution plan. func (c *HTTPConnector) MutationExplain(ctx context.Context, configuration *configuration.Configuration, state *State, request *schema.MutationRequest) (*schema.ExplainResponse, error) { if len(request.Operations) == 0 { - return &schema.ExplainResponse{ - Details: schema.ExplainResponseDetails{}, - }, nil + return nil, schema.BadRequestError("mutation operations must not be empty", nil) } + operation := request.Operations[0] switch operation.Type { case schema.MutationOperationProcedure: + if operation.Name == internal.ProcedureSendHTTPRequest { + return internal.NewRawRequestBuilder(operation, configuration.ForwardHeaders).Explain() + } + requests, err := c.explainProcedure(&operation) if err != nil { return nil, err @@ -106,7 +109,15 @@ func (c *HTTPConnector) execMutationOperation(parentCtx context.Context, state * ctx, span := state.Tracer.Start(parentCtx, fmt.Sprintf("Execute Operation %d", index)) defer span.End() - requests, err := c.explainProcedure(&operation) + var requests *internal.RequestBuilderResults + var err error + if operation.Name == internal.ProcedureSendHTTPRequest { + requests, err = internal.NewRawRequestBuilder(operation, c.config.ForwardHeaders).Build() + requests.Operation = &c.procSendHttpRequest + } else { + requests, err = c.explainProcedure(&operation) + } + if err != nil { span.SetStatus(codes.Error, "failed to explain mutation") span.RecordError(err) diff --git a/connector/mutation_test.go b/connector/mutation_test.go new file mode 100644 index 0000000..5033503 --- /dev/null +++ b/connector/mutation_test.go @@ -0,0 +1,16 @@ +package connector + +import ( + "log/slog" + "testing" + + "github.com/hasura/ndc-sdk-go/ndctest" +) + +func TestRawHTTPRequest(t *testing.T) { + slog.SetLogLoggerLevel(slog.LevelDebug) + ndctest.TestConnector(t, NewHTTPConnector(), ndctest.TestConnectorOptions{ + Configuration: "testdata/jsonplaceholder", + TestDataDir: "testdata/raw", + }) +} diff --git a/connector/query.go b/connector/query.go index a21a5c3..528de60 100644 --- a/connector/query.go +++ b/connector/query.go @@ -159,17 +159,6 @@ func (c *HTTPConnector) serializeExplainResponse(ctx context.Context, requests * explainResp.Details["body"] = string(bodyBytes) } - if httpRequest.Body != nil { - bodyBytes, err := io.ReadAll(httpRequest.Body) - if err != nil { - return nil, schema.InternalServerError("failed to read request body", map[string]any{ - "cause": err.Error(), - }) - } - httpRequest.Body = nil - explainResp.Details["body"] = string(bodyBytes) - } - req, cancel, err := httpRequest.CreateRequest(ctx) if err != nil { return nil, err @@ -177,9 +166,9 @@ func (c *HTTPConnector) serializeExplainResponse(ctx context.Context, requests * defer cancel() // mask sensitive forwarded headers if exists - for key := range req.Header { + for key, value := range req.Header { if internal.IsSensitiveHeader(key) { - req.Header.Set(key, restUtils.MaskString(req.Header.Get(key))) + req.Header.Set(key, restUtils.MaskString(value[0])) } } diff --git a/connector/schema.go b/connector/schema.go index 90a7067..2d1ee3b 100644 --- a/connector/schema.go +++ b/connector/schema.go @@ -5,6 +5,7 @@ import ( "encoding/json" "log/slog" + "github.com/hasura/ndc-http/connector/internal" "github.com/hasura/ndc-http/ndc-http-schema/configuration" "github.com/hasura/ndc-sdk-go/schema" ) @@ -16,27 +17,29 @@ func (c *HTTPConnector) GetSchema(ctx context.Context, configuration *configurat // ApplyNDCHttpSchemas applies slice of raw NDC HTTP schemas to the connector func (c *HTTPConnector) ApplyNDCHttpSchemas(ctx context.Context, config *configuration.Configuration, schemas []configuration.NDCHttpRuntimeSchema, logger *slog.Logger) error { - ndcSchema, metadata, errs := configuration.MergeNDCHttpSchemas(config, schemas) + httpSchema, metadata, errs := configuration.MergeNDCHttpSchemas(config, schemas) if len(errs) > 0 { printSchemaValidationError(logger, errs) - if ndcSchema == nil || config.Strict { + if httpSchema == nil || config.Strict { return errBuildSchemaFailed } } for _, meta := range metadata { - if err := c.upstreams.Register(ctx, &meta, ndcSchema); err != nil { + if err := c.upstreams.Register(ctx, &meta, httpSchema); err != nil { return err } } - schemaBytes, err := json.Marshal(ndcSchema.ToSchemaResponse()) + ndcSchema, procSendHttp := internal.ApplyDefaultConnectorSchema(httpSchema.ToSchemaResponse(), config.ForwardHeaders) + schemaBytes, err := json.Marshal(ndcSchema) if err != nil { return err } c.metadata = metadata c.rawSchema = schema.NewRawSchemaResponseUnsafe(schemaBytes) + c.procSendHttpRequest = procSendHttp return nil } diff --git a/connector/testdata/raw/mutation/cenyzlota-2013-01-02/expected.json b/connector/testdata/raw/mutation/cenyzlota-2013-01-02/expected.json new file mode 100644 index 0000000..c962b23 --- /dev/null +++ b/connector/testdata/raw/mutation/cenyzlota-2013-01-02/expected.json @@ -0,0 +1,13 @@ +{ + "operation_results": [ + { + "result": [ + { + "cena": 165.83, + "data": "2013-01-02" + } + ], + "type": "procedure" + } + ] +} diff --git a/connector/testdata/raw/mutation/cenyzlota-2013-01-02/request.json b/connector/testdata/raw/mutation/cenyzlota-2013-01-02/request.json new file mode 100644 index 0000000..d3d0454 --- /dev/null +++ b/connector/testdata/raw/mutation/cenyzlota-2013-01-02/request.json @@ -0,0 +1,23 @@ +{ + "collection_relationships": {}, + "operations": [ + { + "type": "procedure", + "name": "sendHttpRequest", + "arguments": { + "additionalHeaders": { + "Accept": "application/json" + }, + "body": null, + "method": "get", + "retry": { + "delay": 1000, + "httpStatus": [500], + "times": 1 + }, + "timeout": 30, + "url": "https://api.nbp.pl/api/cenyzlota/2013-01-02" + } + } + ] +} diff --git a/connector/testdata/raw/mutation/cenyzlota/expected.json b/connector/testdata/raw/mutation/cenyzlota/expected.json new file mode 100644 index 0000000..4ca78d8 --- /dev/null +++ b/connector/testdata/raw/mutation/cenyzlota/expected.json @@ -0,0 +1,103 @@ +{ + "operation_results": [ + { + "result": { + "CenaZlota": [ + { + "Cena": "165.83", + "Data": "2013-01-02" + }, + { + "Cena": "166.97", + "Data": "2013-01-03" + }, + { + "Cena": "167.43", + "Data": "2013-01-04" + }, + { + "Cena": "167.98", + "Data": "2013-01-07" + }, + { + "Cena": "167.26", + "Data": "2013-01-08" + }, + { + "Cena": "167.48", + "Data": "2013-01-09" + }, + { + "Cena": "167.98", + "Data": "2013-01-10" + }, + { + "Cena": "167.59", + "Data": "2013-01-11" + }, + { + "Cena": "164.61", + "Data": "2013-01-14" + }, + { + "Cena": "165.18", + "Data": "2013-01-15" + }, + { + "Cena": "166.14", + "Data": "2013-01-16" + }, + { + "Cena": "167.58", + "Data": "2013-01-17" + }, + { + "Cena": "166.14", + "Data": "2013-01-18" + }, + { + "Cena": "167.89", + "Data": "2013-01-21" + }, + { + "Cena": "170.11", + "Data": "2013-01-22" + }, + { + "Cena": "170.34", + "Data": "2013-01-23" + }, + { + "Cena": "169.51", + "Data": "2013-01-24" + }, + { + "Cena": "169.23", + "Data": "2013-01-25" + }, + { + "Cena": "166.44", + "Data": "2013-01-28" + }, + { + "Cena": "165.50", + "Data": "2013-01-29" + }, + { + "Cena": "167.01", + "Data": "2013-01-30" + }, + { + "Cena": "166.85", + "Data": "2013-01-31" + } + ], + "attributes": { + "xsd": "http://www.w3.org/2001/XMLSchema", + "xsi": "http://www.w3.org/2001/XMLSchema-instance" + } + }, + "type": "procedure" + } + ] +} diff --git a/connector/testdata/raw/mutation/cenyzlota/request.json b/connector/testdata/raw/mutation/cenyzlota/request.json new file mode 100644 index 0000000..263e243 --- /dev/null +++ b/connector/testdata/raw/mutation/cenyzlota/request.json @@ -0,0 +1,23 @@ +{ + "collection_relationships": {}, + "operations": [ + { + "type": "procedure", + "name": "sendHttpRequest", + "arguments": { + "additionalHeaders": { + "Accept": "application/xml" + }, + "body": null, + "method": "get", + "retry": { + "delay": 1000, + "httpStatus": [500], + "times": 1 + }, + "timeout": 30, + "url": "https://api.nbp.pl/api/cenyzlota/2013-01-01/2013-01-31" + } + } + ] +} diff --git a/connector/testdata/raw/mutation/delete/expected.json b/connector/testdata/raw/mutation/delete/expected.json new file mode 100644 index 0000000..876aac2 --- /dev/null +++ b/connector/testdata/raw/mutation/delete/expected.json @@ -0,0 +1,8 @@ +{ + "operation_results": [ + { + "result": {}, + "type": "procedure" + } + ] +} diff --git a/connector/testdata/raw/mutation/delete/request.json b/connector/testdata/raw/mutation/delete/request.json new file mode 100644 index 0000000..d5c1901 --- /dev/null +++ b/connector/testdata/raw/mutation/delete/request.json @@ -0,0 +1,13 @@ +{ + "collection_relationships": {}, + "operations": [ + { + "type": "procedure", + "name": "sendHttpRequest", + "arguments": { + "method": "delete", + "url": "https://jsonplaceholder.typicode.com/posts/1" + } + } + ] +} diff --git a/connector/testdata/raw/mutation/image/expected.json b/connector/testdata/raw/mutation/image/expected.json new file mode 100644 index 0000000..66a18da --- /dev/null +++ b/connector/testdata/raw/mutation/image/expected.json @@ -0,0 +1,8 @@ +{ + "operation_results": [ + { + "result": "\u003csvg width=\"32\" height=\"32\" viewBox=\"0 0 32 32\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"\u003e\n\u003cpath fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M16 0C7.16 0 0 7.16 0 16C0 23.08 4.58 29.06 10.94 31.18C11.74 31.32 12.04 30.84 12.04 30.42C12.04 30.04 12.02 28.78 12.02 27.44C8 28.18 6.96 26.46 6.64 25.56C6.46 25.1 5.68 23.68 5 23.3C4.44 23 3.64 22.26 4.98 22.24C6.24 22.22 7.14 23.4 7.44 23.88C8.88 26.3 11.18 25.62 12.1 25.2C12.24 24.16 12.66 23.46 13.12 23.06C9.56 22.66 5.84 21.28 5.84 15.16C5.84 13.42 6.46 11.98 7.48 10.86C7.32 10.46 6.76 8.82 7.64 6.62C7.64 6.62 8.98 6.2 12.04 8.26C13.32 7.9 14.68 7.72 16.04 7.72C17.4 7.72 18.76 7.9 20.04 8.26C23.1 6.18 24.44 6.62 24.44 6.62C25.32 8.82 24.76 10.46 24.6 10.86C25.62 11.98 26.24 13.4 26.24 15.16C26.24 21.3 22.5 22.66 18.94 23.06C19.52 23.56 20.02 24.52 20.02 26.02C20.02 28.16 20 29.88 20 30.42C20 30.84 20.3 31.34 21.1 31.18C27.42 29.06 32 23.06 32 16C32 7.16 24.84 0 16 0V0Z\" fill=\"#24292E\"/\u003e\n\u003c/svg\u003e\n", + "type": "procedure" + } + ] +} diff --git a/connector/testdata/raw/mutation/image/request.json b/connector/testdata/raw/mutation/image/request.json new file mode 100644 index 0000000..6f55889 --- /dev/null +++ b/connector/testdata/raw/mutation/image/request.json @@ -0,0 +1,13 @@ +{ + "collection_relationships": {}, + "operations": [ + { + "type": "procedure", + "name": "sendHttpRequest", + "arguments": { + "method": "get", + "url": "https://github.githubassets.com/favicons/favicon.svg" + } + } + ] +} diff --git a/connector/testdata/raw/mutation/post/expected.json b/connector/testdata/raw/mutation/post/expected.json new file mode 100644 index 0000000..61dc4f4 --- /dev/null +++ b/connector/testdata/raw/mutation/post/expected.json @@ -0,0 +1,13 @@ +{ + "operation_results": [ + { + "result": { + "body": "A test post", + "id": 101, + "title": "Hello world", + "userId": 10 + }, + "type": "procedure" + } + ] +} diff --git a/connector/testdata/raw/mutation/post/request.json b/connector/testdata/raw/mutation/post/request.json new file mode 100644 index 0000000..d307e6b --- /dev/null +++ b/connector/testdata/raw/mutation/post/request.json @@ -0,0 +1,19 @@ +{ + "collection_relationships": {}, + "operations": [ + { + "type": "procedure", + "name": "sendHttpRequest", + "arguments": { + "body": { + "id": 101, + "title": "Hello world", + "userId": 10, + "body": "A test post" + }, + "method": "post", + "url": "https://jsonplaceholder.typicode.com/posts" + } + } + ] +} diff --git a/ndc-http-schema/configuration/schema.go b/ndc-http-schema/configuration/schema.go index 73ef676..c92ffc6 100644 --- a/ndc-http-schema/configuration/schema.go +++ b/ndc-http-schema/configuration/schema.go @@ -330,7 +330,7 @@ func buildHeadersForwardingResponse(config *Configuration, restSchema *rest.NDCH func applyForwardingHeadersArgument(config *Configuration, info *rest.OperationInfo) { if config.ForwardHeaders.Enabled && config.ForwardHeaders.ArgumentField != nil { - info.Arguments[*config.ForwardHeaders.ArgumentField] = headersArguments + info.Arguments[*config.ForwardHeaders.ArgumentField] = NewHeadersArgumentInfo() } } @@ -411,20 +411,7 @@ func validateRequestSchema(req *rest.Request, defaultMethod string) (*rest.Reque func createHeaderForwardingResponseTypes(restSchema *rest.NDCHttpSchema, operationName string, resultType schema.Type, settings *ForwardResponseHeadersSettings) schema.Type { objectName := restUtils.ToPascalCase(operationName) + "HeadersResponse" - objectType := rest.ObjectType{ - Fields: map[string]rest.ObjectField{ - settings.HeadersField: { - ObjectField: schema.ObjectField{ - Type: schema.NewNullableNamedType(string(rest.ScalarJSON)).Encode(), - }, - }, - settings.ResultField: { - ObjectField: schema.ObjectField{ - Type: resultType, - }, - }, - }, - } + objectType := NewHeaderForwardingResponseObjectType(resultType, settings) restSchema.ObjectTypes[objectName] = objectType @@ -444,3 +431,31 @@ func cloneOperationInfo(operation rest.OperationInfo, req *rest.Request) rest.Op ResultType: operation.ResultType, } } + +// NewHeaderForwardingResponseObjectType creates a new type for header forwarding response. +func NewHeaderForwardingResponseObjectType(resultType schema.Type, settings *ForwardResponseHeadersSettings) rest.ObjectType { + return rest.ObjectType{ + Fields: map[string]rest.ObjectField{ + settings.HeadersField: { + ObjectField: schema.ObjectField{ + Type: schema.NewNullableNamedType(string(rest.ScalarJSON)).Encode(), + }, + }, + settings.ResultField: { + ObjectField: schema.ObjectField{ + Type: resultType, + }, + }, + }, + } +} + +// NewHeadersArgumentInfo creates a new forwarding headers argument information +func NewHeadersArgumentInfo() rest.ArgumentInfo { + return rest.ArgumentInfo{ + ArgumentInfo: schema.ArgumentInfo{ + Description: utils.ToPtr("Headers forwarded from the Hasura engine"), + Type: schema.NewNullableNamedType(string(rest.ScalarJSON)).Encode(), + }, + } +} diff --git a/ndc-http-schema/configuration/types.go b/ndc-http-schema/configuration/types.go index 80bbe59..7b9c375 100644 --- a/ndc-http-schema/configuration/types.go +++ b/ndc-http-schema/configuration/types.go @@ -284,13 +284,6 @@ var distributedObjectType rest.ObjectType = rest.ObjectType{ }, } -var headersArguments = rest.ArgumentInfo{ - ArgumentInfo: schema.ArgumentInfo{ - Description: utils.ToPtr("Headers forwarded from the Hasura engine"), - Type: schema.NewNullableNamedType(string(rest.ScalarJSON)).Encode(), - }, -} - var httpSingleOptionsArgument = rest.ArgumentInfo{ ArgumentInfo: schema.ArgumentInfo{ Description: singleObjectType.Description, diff --git a/ndc-http-schema/schema/schema.go b/ndc-http-schema/schema/schema.go index e3f096e..5c87fb7 100644 --- a/ndc-http-schema/schema/schema.go +++ b/ndc-http-schema/schema/schema.go @@ -173,6 +173,27 @@ type RetryPolicy struct { HTTPStatus []int `json:"httpStatus,omitempty" mapstructure:"httpStatus" yaml:"httpStatus,omitempty"` } +// Schema returns the object type schema of this type +func (rp RetryPolicy) Schema() schema.ObjectType { + return schema.ObjectType{ + Description: utils.ToPtr("Retry policy of request"), + Fields: schema.ObjectTypeFields{ + "times": { + Description: utils.ToPtr("Number of retry times"), + Type: schema.NewNamedType(string(ScalarInt32)).Encode(), + }, + "delay": { + Description: utils.ToPtr("Delay retry delay in milliseconds"), + Type: schema.NewNullableType(schema.NewNamedType(string(ScalarInt32))).Encode(), + }, + "httpStatus": { + Description: utils.ToPtr("List of HTTP status the connector will retry on"), + Type: schema.NewNullableType(schema.NewArrayType(schema.NewNamedType(string(ScalarInt32)))).Encode(), + }, + }, + } +} + // EncodingObject represents the [Encoding Object] that contains serialization strategy for application/x-www-form-urlencoded // // [Encoding Object]: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#encoding-object diff --git a/ndc-http-schema/schema/setting.go b/ndc-http-schema/schema/setting.go index 09ce04b..2917912 100644 --- a/ndc-http-schema/schema/setting.go +++ b/ndc-http-schema/schema/setting.go @@ -78,7 +78,7 @@ func (ss *ServerConfig) Validate() error { return errors.New("url is required for server") } - _, err = parseHttpURL(rawURL) + _, err = ParseHttpURL(rawURL) if err != nil { return fmt.Errorf("server url: %w", err) } @@ -98,7 +98,7 @@ func (ss ServerConfig) GetURL() (*url.URL, error) { if err != nil { return nil, err } - urlValue, err := parseHttpURL(rawURL) + urlValue, err := ParseHttpURL(rawURL) if err != nil { return nil, fmt.Errorf("server url: %w", err) } @@ -386,8 +386,8 @@ func (apv ArgumentPresetValueForwardHeader) GetType() ArgumentPresetValueType { return apv.Type } -// parseHttpURL parses and validate if the URL has HTTP scheme -func parseHttpURL(input string) (*url.URL, error) { +// ParseHttpURL parses and validate if the URL has HTTP scheme +func ParseHttpURL(input string) (*url.URL, error) { if !strings.HasPrefix(input, "https://") && !strings.HasPrefix(input, "http://") { return nil, errors.New("invalid HTTP URL " + input) } @@ -400,7 +400,7 @@ func ParseRelativeOrHttpURL(input string) (*url.URL, error) { return &url.URL{Path: input}, nil } - return parseHttpURL(input) + return ParseHttpURL(input) } func getStringFromAnyMap(input map[string]any, key string) (string, error) { diff --git a/ndc-http-schema/version/version.go b/ndc-http-schema/version/version.go index 33ddc76..6db7448 100644 --- a/ndc-http-schema/version/version.go +++ b/ndc-http-schema/version/version.go @@ -1,9 +1,40 @@ // Package version implements cli handling. package version +import "runtime/debug" + // DevVersion is the version string for development versions. const DevVersion = "dev" // BuildVersion is the version string with which CLI is built. Set during // the build time. -var BuildVersion = DevVersion +var BuildVersion = "" + +func init() { //nolint:all + initBuildVersion() +} + +func initBuildVersion() { + if BuildVersion != "" { + return + } + + BuildVersion = DevVersion + bi, ok := debug.ReadBuildInfo() + if !ok { + return + } + if bi.Main.Version != "" { + BuildVersion = bi.Main.Version + + return + } + + for _, s := range bi.Settings { + if s.Key == "vcs.revision" && s.Value != "" { + BuildVersion = s.Value + + return + } + } +} diff --git a/server/main.go b/server/main.go index a1f816b..e6c678d 100644 --- a/server/main.go +++ b/server/main.go @@ -2,6 +2,7 @@ package main import ( rest "github.com/hasura/ndc-http/connector" + "github.com/hasura/ndc-http/ndc-http-schema/version" "github.com/hasura/ndc-sdk-go/connector" ) @@ -17,6 +18,7 @@ func main() { rest.NewHTTPConnector(), connector.WithMetricsPrefix("ndc_http"), connector.WithDefaultServiceName("ndc_http"), + connector.WithVersion(version.BuildVersion), ); err != nil { panic(err) } diff --git a/tests/engine/app/metadata/myapi-types.hml b/tests/engine/app/metadata/myapi-types.hml index f495122..44d6c3e 100644 --- a/tests/engine/app/metadata/myapi-types.hml +++ b/tests/engine/app/metadata/myapi-types.hml @@ -132,3 +132,79 @@ definition: graphql: comparisonExpressionTypeName: Boolean_comparison_exp +--- +kind: ScalarType +version: v1 +definition: + name: RawHttpMethod + graphql: + typeName: RawHttpMethod + +--- +kind: BooleanExpressionType +version: v1 +definition: + name: RawHttpMethod_bool_exp + operand: + scalar: + type: RawHttpMethod + comparisonOperators: [] + dataConnectorOperatorMapping: + - dataConnectorName: myapi + dataConnectorScalarType: RawHttpMethod + operatorMapping: {} + logicalOperators: + enable: true + isNull: + enable: true + graphql: + typeName: RawHttpMethod_bool_exp + +--- +kind: DataConnectorScalarRepresentation +version: v1 +definition: + dataConnectorName: myapi + dataConnectorScalarType: RawHttpMethod + representation: RawHttpMethod + graphql: + comparisonExpressionTypeName: RawHttpMethod_comparison_exp + +--- +kind: ScalarType +version: v1 +definition: + name: JSON + graphql: + typeName: JSON + +--- +kind: BooleanExpressionType +version: v1 +definition: + name: JSON_bool_exp + operand: + scalar: + type: JSON + comparisonOperators: [] + dataConnectorOperatorMapping: + - dataConnectorName: myapi + dataConnectorScalarType: JSON + operatorMapping: {} + logicalOperators: + enable: true + isNull: + enable: true + graphql: + typeName: JSON_bool_exp + +--- +kind: DataConnectorScalarRepresentation +version: v1 +definition: + dataConnectorName: myapi + dataConnectorScalarType: JSON + representation: JSON + graphql: + comparisonExpressionTypeName: JSON_comparison_exp + diff --git a/tests/engine/app/metadata/myapi.hml b/tests/engine/app/metadata/myapi.hml index 1789f5a..cf41c79 100644 --- a/tests/engine/app/metadata/myapi.hml +++ b/tests/engine/app/metadata/myapi.hml @@ -35,6 +35,17 @@ definition: type: json aggregate_functions: {} comparison_operators: {} + RawHttpMethod: + representation: + type: enum + one_of: + - get + - post + - put + - patch + - delete + aggregate_functions: {} + comparison_operators: {} String: representation: type: string @@ -156,6 +167,30 @@ definition: underlying_type: type: named name: Int64 + RetryPolicy: + description: Retry policy of request + fields: + delay: + description: Delay retry delay in milliseconds + type: + type: nullable + underlying_type: + type: named + name: Int32 + httpStatus: + description: List of HTTP status the connector will retry on + type: + type: nullable + underlying_type: + type: array + element_type: + type: named + name: Int32 + times: + description: Number of retry times + type: + type: named + name: Int32 Todo: fields: completed: @@ -707,6 +742,61 @@ definition: result_type: type: named name: Post + - name: sendHttpRequest + description: Send an HTTP request + arguments: + additionalHeaders: + description: Additional request headers + type: + type: nullable + underlying_type: + type: named + name: JSON + body: + description: Request body + type: + type: nullable + underlying_type: + type: named + name: JSON + headers: + description: Headers forwarded from the Hasura engine + type: + type: nullable + underlying_type: + type: named + name: JSON + method: + description: Request method + type: + type: nullable + underlying_type: + type: named + name: RawHttpMethod + retry: + description: Retry policy + type: + type: nullable + underlying_type: + type: named + name: RetryPolicy + timeout: + description: Request timeout in seconds + type: + type: nullable + underlying_type: + type: named + name: Int32 + url: + description: Request URL + type: + type: named + name: String + result_type: + type: nullable + underlying_type: + type: named + name: JSON capabilities: version: 0.1.6 capabilities: diff --git a/tests/engine/app/metadata/sendHttpRequest.hml b/tests/engine/app/metadata/sendHttpRequest.hml new file mode 100644 index 0000000..11f5435 --- /dev/null +++ b/tests/engine/app/metadata/sendHttpRequest.hml @@ -0,0 +1,79 @@ +--- +kind: ObjectType +version: v1 +definition: + name: RetryPolicy + description: Retry policy of request + fields: + - name: delay + type: Int32 + description: Delay retry delay in milliseconds + - name: httpStatus + type: "[Int32!]" + description: List of HTTP status the connector will retry on + - name: times + type: Int32! + description: Number of retry times + graphql: + typeName: RetryPolicy + inputTypeName: RetryPolicy_input + dataConnectorTypeMapping: + - dataConnectorName: myapi + dataConnectorObjectType: RetryPolicy + +--- +kind: TypePermissions +version: v1 +definition: + typeName: RetryPolicy + permissions: + - role: admin + output: + allowedFields: + - delay + - httpStatus + - times + +--- +kind: Command +version: v1 +definition: + name: sendHttpRequest + outputType: JSON + arguments: + - name: additionalHeaders + type: JSON + description: Additional request headers + - name: body + type: JSON + description: Request body + - name: method + type: RawHttpMethod + description: Request method + - name: retry + type: RetryPolicy + description: Retry policy + - name: timeout + type: Int32 + description: Request timeout in seconds + - name: url + type: String! + description: Request URL + source: + dataConnectorName: myapi + dataConnectorCommand: + procedure: sendHttpRequest + graphql: + rootFieldName: sendHttpRequest + rootFieldKind: Mutation + description: Send an HTTP request + +--- +kind: CommandPermissions +version: v1 +definition: + commandName: sendHttpRequest + permissions: + - role: admin + allowExecution: true +