Skip to content

Commit b152004

Browse files
authored
feat: schemaless HTTP requests (#45)
Add a new sendHttpRequest mutation to send schemaless HTTP requests. It's used as a dynamic GraphQL to REST proxy.
1 parent 367d03a commit b152004

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+1620
-168
lines changed

.github/workflows/release.yaml

+2
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ jobs:
5151
tags: ${{ steps.docker-metadata.outputs.tags }}
5252
labels: ${{ steps.docker-metadata.outputs.labels }}
5353
platforms: linux/amd64,linux/arm64
54+
build-args: |
55+
VERSION=${{ steps.get-version.outputs.tagged_version }}
5456
5557
build-cli-and-manifests:
5658
name: Build the CLI binaries and manifests

Dockerfile

+6-1
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,16 @@
22
FROM golang:1.23 AS builder
33

44
WORKDIR /app
5+
6+
ARG VERSION
57
COPY ndc-http-schema ./ndc-http-schema
68
COPY go.mod go.sum go.work ./
79
RUN go mod download
810
COPY . .
9-
RUN CGO_ENABLED=0 go build -v -o ndc-cli ./server
11+
12+
RUN CGO_ENABLED=0 go build \
13+
-ldflags "-X github.com/hasura/ndc-http/ndc-http-schema/version.BuildVersion=${VERSION}" \
14+
-v -o ndc-cli ./server
1015

1116
# stage 2: production image
1217
FROM gcr.io/distroless/static-debian12:nonroot

README.md

+7-83
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,13 @@ The connector can automatically transform OpenAPI 2.0 and 3.0 definitions to NDC
1212

1313
- [No code. Configuration based](#configuration).
1414
- Composable API collections.
15-
- [Supported many API specifications](#supported-specs).
15+
- [Supported many API specifications](./docs/configuration.md#supported-specs).
1616
- [Supported authentication](./docs/authentication.md).
1717
- [Supported headers forwarding](./docs/authentication.md#headers-forwarding).
1818
- [Supported argument presets](./docs/argument_presets.md).
1919
- [Supported timeout and retry](#timeout-and-retry).
2020
- Supported concurrency and [sending distributed requests](./docs/distribution.md) to multiple servers.
21+
- [GraphQL-to-REST proxy](./docs/schemaless_request.md).
2122

2223
**Supported request types**
2324

@@ -55,96 +56,19 @@ The connector can automatically transform OpenAPI 2.0 and 3.0 definitions to NDC
5556
| OAuth 2.0 || Built-in support for the `client_credentials` grant. Other grant types require forwarding access tokens from headers by the Hasura engine |
5657
| mTLS || |
5758

58-
## Quick start
59+
## Get Started
5960

60-
Start the connector server at http://localhost:8080 using the [JSON Placeholder](https://jsonplaceholder.typicode.com/) APIs.
61-
62-
```go
63-
go run ./server serve --configuration ./connector/testdata/jsonplaceholder
64-
```
61+
Follow the [Quick Start Guide](https://hasura.io/docs/3.0/getting-started/overview/) in Hasura DDN docs. At the `Connect to data` step, choose the `hasura/http` data connector from the dropdown. The connector template includes an example that is ready to run.
6562

6663
## Documentation
6764

68-
- [NDC HTTP schema](./ndc-http-schema)
65+
- [Configuration](./docs/configuration.md)
6966
- [Authentication](./docs/authentication.md)
7067
- [Argument Presets](./docs/argument_presets.md)
68+
- [Schemaless Requests](./docs/schemaless_request.md)
7169
- [Distributed Execution](./docs/distribution.md)
7270
- [Recipes](https://github.com/hasura/ndc-http-recipes/tree/main): You can find or request pre-built configuration recipes of popular API services here.
73-
74-
## Configuration
75-
76-
The connector reads `config.{json,yaml}` file in the configuration folder. The file contains information about the schema file path and its specification:
77-
78-
```yaml
79-
files:
80-
- file: swagger.json
81-
spec: openapi2
82-
- file: openapi.yaml
83-
spec: openapi3
84-
trimPrefix: /v1
85-
envPrefix: PET_STORE
86-
- file: schema.json
87-
spec: ndc
88-
```
89-
90-
The config of each element follows the [config schema](https://github.com/hasura/ndc-http/ndc-http-schema/blob/main/config.example.yaml) of `ndc-http-schema`.
91-
92-
You can add many API documentation files into the same connector.
93-
94-
> [!IMPORTANT]
95-
> Conflicted object and scalar types will be ignored. Only the type of the first file is kept in the schema.
96-
97-
### Supported specs
98-
99-
#### OpenAPI
100-
101-
HTTP connector supports both OpenAPI 2 and 3 specifications.
102-
103-
- `oas3`/`openapi3`: OpenAPI 3.0/3.1.
104-
- `oas2`/`openapi2`: OpenAPI 2.0.
105-
106-
#### HTTP Connector schema
107-
108-
Enum: `ndc`
109-
110-
HTTP schema is the native configuration schema which other specs will be converted to behind the scene. The schema extends the NDC Specification with HTTP configuration and can be converted from other specs by the [NDC HTTP schema CLI](./ndc-http-schema).
111-
112-
### Timeout and retry
113-
114-
The global timeout and retry strategy can be configured in each file:
115-
116-
```yaml
117-
files:
118-
- file: swagger.json
119-
spec: oas2
120-
timeout:
121-
value: 30
122-
retry:
123-
times:
124-
value: 1
125-
delay:
126-
# delay between each retry in milliseconds
127-
value: 500
128-
httpStatus: [429, 500, 502, 503]
129-
```
130-
131-
### JSON Patch
132-
133-
You can add JSON patches to extend API documentation files. HTTP connector supports `merge` and `json6902` strategies. JSON patches can be applied before or after the conversion from OpenAPI to HTTP schema configuration. It will be useful if you need to extend or fix some fields in the API documentation such as server URL.
134-
135-
```yaml
136-
files:
137-
- file: openapi.yaml
138-
spec: oas3
139-
patchBefore:
140-
- path: patch-before.yaml
141-
strategy: merge
142-
patchAfter:
143-
- path: patch-after.yaml
144-
strategy: json6902
145-
```
146-
147-
See [the example](./ndc-http-schema/command/testdata/patch) for more context.
71+
- [NDC HTTP schema](./ndc-http-schema)
14872

14973
## License
15074

connector/connector.go

+8-6
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,20 @@ import (
99

1010
"github.com/hasura/ndc-http/connector/internal"
1111
"github.com/hasura/ndc-http/ndc-http-schema/configuration"
12+
rest "github.com/hasura/ndc-http/ndc-http-schema/schema"
1213
"github.com/hasura/ndc-sdk-go/connector"
1314
"github.com/hasura/ndc-sdk-go/schema"
1415
)
1516

1617
// HTTPConnector implements the SDK interface of NDC specification
1718
type HTTPConnector struct {
18-
config *configuration.Configuration
19-
metadata internal.MetadataCollection
20-
capabilities *schema.RawCapabilitiesResponse
21-
rawSchema *schema.RawSchemaResponse
22-
httpClient *http.Client
23-
upstreams *internal.UpstreamManager
19+
config *configuration.Configuration
20+
metadata internal.MetadataCollection
21+
capabilities *schema.RawCapabilitiesResponse
22+
rawSchema *schema.RawSchemaResponse
23+
httpClient *http.Client
24+
upstreams *internal.UpstreamManager
25+
procSendHttpRequest rest.OperationInfo
2426
}
2527

2628
// NewHTTPConnector creates a HTTP connector instance

connector/internal/client.go

+18-8
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,12 @@ func (client *HTTPClient) doRequest(ctx context.Context, request *RetryableReque
292292
attribute.String("network.protocol.name", "http"),
293293
)
294294

295+
var namespace string
296+
if client.requests.Schema != nil && client.requests.Schema.Name != "" {
297+
namespace = client.requests.Schema.Name
298+
span.SetAttributes(attribute.String("db.namespace", namespace))
299+
}
300+
295301
if request.ContentLength > 0 {
296302
span.SetAttributes(attribute.Int64("http.request.body.size", request.ContentLength))
297303
}
@@ -301,8 +307,7 @@ func (client *HTTPClient) doRequest(ctx context.Context, request *RetryableReque
301307
setHeaderAttributes(span, "http.request.header.", request.Headers)
302308

303309
client.propagator.Inject(ctx, propagation.HeaderCarrier(request.Headers))
304-
305-
resp, cancel, err := client.manager.ExecuteRequest(ctx, request, client.requests.Schema.Name)
310+
resp, cancel, err := client.manager.ExecuteRequest(ctx, request, namespace)
306311
if err != nil {
307312
span.SetStatus(codes.Error, "error happened when executing the request")
308313
span.RecordError(err)
@@ -371,7 +376,7 @@ func (client *HTTPClient) evalHTTPResponse(ctx context.Context, span trace.Span,
371376

372377
var result any
373378
switch {
374-
case strings.HasPrefix(contentType, "text/"):
379+
case strings.HasPrefix(contentType, "text/") || strings.HasPrefix(contentType, "image/svg"):
375380
respBody, err := io.ReadAll(resp.Body)
376381
if err != nil {
377382
return nil, nil, schema.NewConnectorError(http.StatusInternalServerError, err.Error(), nil)
@@ -412,13 +417,18 @@ func (client *HTTPClient) evalHTTPResponse(ctx context.Context, span trace.Span,
412417
}
413418
}
414419

415-
responseType, extractErr := client.extractResultType(resultType)
416-
if extractErr != nil {
417-
return nil, nil, extractErr
420+
var err error
421+
if client.requests.Schema == nil || client.requests.Schema.NDCHttpSchema == nil {
422+
err = json.NewDecoder(resp.Body).Decode(&result)
423+
} else {
424+
responseType, extractErr := client.extractResultType(resultType)
425+
if extractErr != nil {
426+
return nil, nil, extractErr
427+
}
428+
429+
result, err = contenttype.NewJSONDecoder(client.requests.Schema.NDCHttpSchema).Decode(resp.Body, responseType)
418430
}
419431

420-
var err error
421-
result, err = contenttype.NewJSONDecoder(client.requests.Schema.NDCHttpSchema).Decode(resp.Body, responseType)
422432
if err != nil {
423433
return nil, nil, schema.NewConnectorError(http.StatusInternalServerError, err.Error(), nil)
424434
}

connector/internal/contenttype/multipart.go

+51
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ func (mfb *MultipartFormEncoder) evalMultipartForm(w *MultipartWriter, bodyInfo
5757
if !ok {
5858
return nil
5959
}
60+
6061
switch bodyType := bodyInfo.Type.Interface().(type) {
6162
case *schema.NullableType:
6263
return mfb.evalMultipartForm(w, &rest.ArgumentInfo{
@@ -73,6 +74,7 @@ func (mfb *MultipartFormEncoder) evalMultipartForm(w *MultipartWriter, bodyInfo
7374
if !ok {
7475
break
7576
}
77+
7678
kind := bodyData.Kind()
7779
switch kind {
7880
case reflect.Map, reflect.Interface:
@@ -276,3 +278,52 @@ func (mfb *MultipartFormEncoder) evalEncodingHeaders(encHeaders map[string]rest.
276278

277279
return results, nil
278280
}
281+
282+
// EncodeArbitrary encodes the unknown data to multipart/form.
283+
func (c *MultipartFormEncoder) EncodeArbitrary(bodyData any) (*bytes.Reader, string, error) {
284+
buffer := new(bytes.Buffer)
285+
writer := NewMultipartWriter(buffer)
286+
287+
reflectValue, ok := utils.UnwrapPointerFromReflectValue(reflect.ValueOf(bodyData))
288+
if ok {
289+
valueMap, ok := reflectValue.Interface().(map[string]any)
290+
if !ok {
291+
return nil, "", fmt.Errorf("invalid body for multipart/form, expected object, got: %s", reflectValue.Kind())
292+
}
293+
294+
for key, value := range valueMap {
295+
if err := c.evalFormDataReflection(writer, key, reflect.ValueOf(value)); err != nil {
296+
return nil, "", fmt.Errorf("invalid body for multipart/form, %s: %w", key, err)
297+
}
298+
}
299+
}
300+
301+
if err := writer.Close(); err != nil {
302+
return nil, "", err
303+
}
304+
305+
reader := bytes.NewReader(buffer.Bytes())
306+
buffer.Reset()
307+
308+
return reader, writer.FormDataContentType(), nil
309+
}
310+
311+
func (c *MultipartFormEncoder) evalFormDataReflection(w *MultipartWriter, key string, reflectValue reflect.Value) error {
312+
reflectValue, ok := utils.UnwrapPointerFromReflectValue(reflectValue)
313+
if !ok {
314+
return nil
315+
}
316+
317+
kind := reflectValue.Kind()
318+
switch kind {
319+
case reflect.Map, reflect.Struct, reflect.Array, reflect.Slice:
320+
return w.WriteJSON(key, reflectValue.Interface(), http.Header{})
321+
default:
322+
value, err := StringifySimpleScalar(reflectValue, kind)
323+
if err != nil {
324+
return err
325+
}
326+
327+
return w.WriteField(key, value, http.Header{})
328+
}
329+
}

connector/internal/contenttype/url_encode.go

+30-4
Original file line numberDiff line numberDiff line change
@@ -33,28 +33,51 @@ func NewURLParameterEncoder(schema *rest.NDCHttpSchema, contentType string) *URL
3333
}
3434
}
3535

36-
func (c *URLParameterEncoder) Encode(bodyInfo *rest.ArgumentInfo, bodyData any) (io.ReadSeeker, error) {
36+
func (c *URLParameterEncoder) Encode(bodyInfo *rest.ArgumentInfo, bodyData any) (io.ReadSeeker, int64, error) {
3737
queryParams, err := c.EncodeParameterValues(&rest.ObjectField{
3838
ObjectField: schema.ObjectField{
3939
Type: bodyInfo.Type,
4040
},
4141
HTTP: bodyInfo.HTTP.Schema,
4242
}, reflect.ValueOf(bodyData), []string{"body"})
4343
if err != nil {
44-
return nil, err
44+
return nil, 0, err
4545
}
4646

4747
if len(queryParams) == 0 {
48-
return nil, nil
48+
return nil, 0, nil
4949
}
5050
q := url.Values{}
5151
for _, qp := range queryParams {
5252
keys := qp.Keys()
5353
EvalQueryParameterURL(&q, "", bodyInfo.HTTP.EncodingObject, keys, qp.Values())
5454
}
5555
rawQuery := EncodeQueryValues(q, true)
56+
result := bytes.NewReader([]byte(rawQuery))
5657

57-
return bytes.NewReader([]byte(rawQuery)), nil
58+
return result, result.Size(), nil
59+
}
60+
61+
// Encode marshals the arbitrary body to xml bytes.
62+
func (c *URLParameterEncoder) EncodeArbitrary(bodyData any) (io.ReadSeeker, int64, error) {
63+
queryParams, err := c.encodeParameterReflectionValues(reflect.ValueOf(bodyData), []string{"body"})
64+
if err != nil {
65+
return nil, 0, err
66+
}
67+
68+
if len(queryParams) == 0 {
69+
return nil, 0, nil
70+
}
71+
q := url.Values{}
72+
encObject := rest.EncodingObject{}
73+
for _, qp := range queryParams {
74+
keys := qp.Keys()
75+
EvalQueryParameterURL(&q, "", encObject, keys, qp.Values())
76+
}
77+
rawQuery := EncodeQueryValues(q, true)
78+
result := bytes.NewReader([]byte(rawQuery))
79+
80+
return result, result.Size(), nil
5881
}
5982

6083
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
382405
return strings.Join(resultKeys, "")
383406
}
384407

408+
// EvalQueryParameterURL evaluate the query parameter URL
385409
func EvalQueryParameterURL(q *url.Values, name string, encObject rest.EncodingObject, keys Keys, values []string) {
386410
if len(values) == 0 {
387411
return
@@ -411,6 +435,7 @@ func EvalQueryParameterURL(q *url.Values, name string, encObject rest.EncodingOb
411435
}
412436
}
413437

438+
// EncodeQueryValues encode query values to string.
414439
func EncodeQueryValues(qValues url.Values, allowReserved bool) string {
415440
if !allowReserved {
416441
return qValues.Encode()
@@ -433,6 +458,7 @@ func EncodeQueryValues(qValues url.Values, allowReserved bool) string {
433458
return builder.String()
434459
}
435460

461+
// SetHeaderParameters set parameters to request headers
436462
func SetHeaderParameters(header *http.Header, param *rest.RequestParameter, queryParams ParameterItems) {
437463
defaultParam := queryParams.FindDefault()
438464
// the param is an array

connector/internal/contenttype/url_encode_test.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -692,7 +692,7 @@ func TestCreateFormURLEncoded(t *testing.T) {
692692
assert.NilError(t, json.Unmarshal([]byte(tc.RawArguments), &arguments))
693693
argumentInfo := info.Arguments["body"]
694694
builder := NewURLParameterEncoder(ndcSchema, rest.ContentTypeFormURLEncoded)
695-
buf, err := builder.Encode(&argumentInfo, arguments["body"])
695+
buf, _, err := builder.Encode(&argumentInfo, arguments["body"])
696696
assert.NilError(t, err)
697697
result, err := io.ReadAll(buf)
698698
assert.NilError(t, err)

connector/internal/contenttype/xml_decode.go

+4
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ func (c *XMLDecoder) Decode(r io.Reader, resultType schema.Type) (any, error) {
4444
return nil, fmt.Errorf("failed to decode the xml result: %w", err)
4545
}
4646

47+
if c.schema == nil {
48+
return decodeArbitraryXMLBlock(xmlTree), nil
49+
}
50+
4751
result, err := c.evalXMLField(xmlTree, "", rest.ObjectField{
4852
ObjectField: schema.ObjectField{
4953
Type: resultType,

0 commit comments

Comments
 (0)