From 31a87a933950b6d19663cf19b313d5524dbd15ef Mon Sep 17 00:00:00 2001 From: Toan Nguyen Date: Sat, 30 Nov 2024 11:34:10 +0700 Subject: [PATCH] Support OAuth 2.0 client credentials flow (#33) --- .github/workflows/test.yaml | 3 + compose.yaml | 43 ++ connector/connector.go | 14 +- connector/connector_test.go | 230 ++++++++++- connector/internal/auth/api_key.go | 74 ++++ connector/internal/auth/auth.go | 87 +++++ connector/internal/auth/basic.go | 80 ++++ connector/internal/auth/http.go | 104 +++++ connector/internal/auth/oauth2.go | 93 +++++ connector/internal/client.go | 54 +-- .../{decode.go => contenttype/data_uri.go} | 2 +- .../data_uri_test.go} | 2 +- .../internal/{ => contenttype}/multipart.go | 2 +- connector/internal/contenttype/utils.go | 27 ++ .../internal/{ => contenttype}/xml_decode.go | 63 ++- .../{ => contenttype}/xml_decode_test.go | 14 +- .../internal/{ => contenttype}/xml_encode.go | 6 +- .../{ => contenttype}/xml_encode_test.go | 2 +- connector/internal/request.go | 250 +----------- connector/internal/request_builder.go | 11 +- connector/internal/request_parameter.go | 3 +- connector/internal/types.go | 8 +- connector/internal/upstream.go | 367 ++++++++++++++++++ connector/internal/utils.go | 105 +---- connector/mutation.go | 9 +- connector/query.go | 26 +- connector/schema.go | 8 +- connector/testdata/auth/schema.yaml | 113 +++++- connector/types.go | 6 +- go.mod | 15 +- go.sum | 26 +- ndc-http-schema/configuration/schema.go | 15 +- ndc-http-schema/go.mod | 6 +- ndc-http-schema/go.sum | 12 +- .../jsonschema/configuration.schema.json | 4 + .../jsonschema/convert-config.schema.json | 4 + ndc-http-schema/jsonschema/generator.go | 4 + .../jsonschema/ndc-http-schema.schema.json | 120 +++++- ndc-http-schema/openapi/internal/oas2.go | 16 +- ndc-http-schema/openapi/internal/oas3.go | 34 +- .../openapi/testdata/petstore2/expected.json | 5 +- .../openapi/testdata/petstore3/expected.json | 3 + ndc-http-schema/schema/auth.go | 201 ++++------ ndc-http-schema/schema/setting.go | 81 +--- ndc-http-schema/schema/setting_test.go | 13 +- ndc-http-schema/utils/file.go | 6 +- ndc-http-schema/utils/string.go | 14 + tests/configuration/config.yaml | 15 +- tests/engine/app/metadata/myapi-types.hml | 20 + tests/engine/app/metadata/myapi.hml | 145 +++++++ .../globals/metadata/compatibility-config.hml | 2 +- tests/hydra.yml | 29 ++ 52 files changed, 1888 insertions(+), 708 deletions(-) create mode 100644 connector/internal/auth/api_key.go create mode 100644 connector/internal/auth/auth.go create mode 100644 connector/internal/auth/basic.go create mode 100644 connector/internal/auth/http.go create mode 100644 connector/internal/auth/oauth2.go rename connector/internal/{decode.go => contenttype/data_uri.go} (98%) rename connector/internal/{decode_test.go => contenttype/data_uri_test.go} (98%) rename connector/internal/{ => contenttype}/multipart.go (99%) create mode 100644 connector/internal/contenttype/utils.go rename connector/internal/{ => contenttype}/xml_decode.go (88%) rename connector/internal/{ => contenttype}/xml_decode_test.go (86%) rename connector/internal/{ => contenttype}/xml_encode.go (98%) rename connector/internal/{ => contenttype}/xml_encode_test.go (99%) create mode 100644 connector/internal/upstream.go create mode 100644 tests/hydra.yml diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index f339db5..f224298 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -18,7 +18,10 @@ jobs: go-version: ${{ env.GO_VERSION }} - name: Run Go unit tests run: | + docker compose up -d hydra hydra-migrate + sleep 5 go test -v -coverpkg=./... -race -timeout 3m -coverprofile=coverage.out.tmp ./... + docker compose down -v cat coverage.out.tmp | grep -v "main.go" > coverage.out - name: Run integration tests run: | diff --git a/compose.yaml b/compose.yaml index dd29402..bcc414d 100644 --- a/compose.yaml +++ b/compose.yaml @@ -12,4 +12,47 @@ services: - local.hasura.dev=host-gateway environment: OTEL_EXPORTER_OTLP_ENDPOINT: http://local.hasura.dev:4317 + HYDRA_PUBLIC_SERVER_URL: http://hydra:4444 + HYDRA_ADMIN_SERVER_URL: http://hydra:4445 HASURA_LOG_LEVEL: debug + + hydra: + image: oryd/hydra:v2.2.0 + ports: + - "4444:4444" # Public port + - "4445:4445" # Admin port + - "5555:5555" # Port for hydra token user + command: serve -c /etc/config/hydra/hydra.yml all --dev + volumes: + - type: volume + source: hydra-sqlite + target: /var/lib/sqlite + read_only: false + - type: bind + source: ./tests/hydra.yml + target: /etc/config/hydra/hydra.yml + environment: + - DSN=sqlite:///var/lib/sqlite/db.sqlite?_fk=true + restart: unless-stopped + depends_on: + hydra-migrate: + required: true + condition: service_completed_successfully + + hydra-migrate: + image: oryd/hydra:v2.2.0 + environment: + - DSN=sqlite:///var/lib/sqlite/db.sqlite?_fk=true + command: migrate -c /etc/config/hydra/hydra.yml sql -e --yes + volumes: + - type: volume + source: hydra-sqlite + target: /var/lib/sqlite + read_only: false + - type: bind + source: ./tests/hydra.yml + target: /etc/config/hydra/hydra.yml + restart: on-failure + +volumes: + hydra-sqlite: diff --git a/connector/connector.go b/connector/connector.go index 764e7bf..fcfc878 100644 --- a/connector/connector.go +++ b/connector/connector.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "net/http" "github.com/hasura/ndc-http/connector/internal" "github.com/hasura/ndc-http/ndc-http-schema/configuration" @@ -19,7 +20,8 @@ type HTTPConnector struct { capabilities *schema.RawCapabilitiesResponse rawSchema *schema.RawSchemaResponse schema *rest.NDCHttpSchema - client internal.Doer + httpClient *http.Client + upstreams *internal.UpstreamManager } // NewHTTPConnector creates a HTTP connector instance @@ -29,7 +31,7 @@ func NewHTTPConnector(opts ...Option) *HTTPConnector { } return &HTTPConnector{ - client: defaultOptions.client, + httpClient: defaultOptions.client, } } @@ -76,11 +78,11 @@ func (c *HTTPConnector) ParseConfiguration(ctx context.Context, configurationDir } } - if err := c.ApplyNDCHttpSchemas(config, schemas, logger); err != nil { - return nil, errInvalidSchema - } - c.config = config + c.upstreams = internal.NewUpstreamManager(c.httpClient, config) + if err := c.ApplyNDCHttpSchemas(ctx, config, schemas, logger); err != nil { + return nil, fmt.Errorf("failed to validate NDC HTTP schema: %w", err) + } return config, nil } diff --git a/connector/connector_test.go b/connector/connector_test.go index 679de25..6774ab8 100644 --- a/connector/connector_test.go +++ b/connector/connector_test.go @@ -12,11 +12,13 @@ import ( "net/http/httptest" "os" "path" + "strings" "sync/atomic" "testing" "time" "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" "gotest.tools/v3/assert" @@ -134,7 +136,7 @@ func TestHTTPConnector_authentication(t *testing.T) { assertHTTPResponse(t, res, http.StatusOK, schema.ExplainResponse{ Details: schema.ExplainResponseDetails{ "url": server.URL + "/pet", - "headers": `{"Api_key":["ran*******(14)"]}`, + "headers": `{"Api_key":["ran*******(14)"],"Content-Type":["application/json"]}`, }, }) }) @@ -244,7 +246,7 @@ func TestHTTPConnector_authentication(t *testing.T) { assertHTTPResponse(t, res, http.StatusOK, schema.ExplainResponse{ Details: schema.ExplainResponseDetails{ "url": server.URL + "/pet/findByStatus?status=available", - "headers": `{"Authorization":["Bearer ran*******(19)"],"X-Custom-Header":["This is a test"]}`, + "headers": `{"Authorization":["Bearer ran*******(19)"],"Content-Type":["application/json"],"X-Custom-Header":["This is a test"]}`, }, }) }) @@ -268,6 +270,92 @@ func TestHTTPConnector_authentication(t *testing.T) { } }) + t.Run("auth_cookie", func(t *testing.T) { + + requestBody := []byte(`{ + "collection": "findPetsCookie", + "query": { + "fields": { + "__value": { + "type": "column", + "column": "__value" + } + } + }, + "arguments": { + "headers": { + "type": "literal", + "value": { + "Cookie": "auth=auth_token" + } + } + }, + "collection_relationships": {} + }`) + + res, err := http.Post(fmt.Sprintf("%s/query", testServer.URL), "application/json", bytes.NewBuffer(requestBody)) + assert.NilError(t, err) + assertHTTPResponse(t, res, http.StatusOK, schema.QueryResponse{ + { + Rows: []map[string]any{ + { + "__value": map[string]any{ + "headers": map[string]any{ + "Content-Type": string("application/json"), + }, + "response": map[string]any{}, + }, + }, + }, + }, + }) + }) + + t.Run("auth_oidc", func(t *testing.T) { + addPetOidcBody := []byte(`{ + "operations": [ + { + "type": "procedure", + "name": "addPetOidc", + "arguments": { + "headers": { + "Authorization": "Bearer random_token" + }, + "body": { + "name": "pet" + } + }, + "fields": { + "type": "object", + "fields": { + "headers": { + "column": "headers", + "type": "column" + }, + "response": { + "column": "response", + "type": "column" + } + } + } + } + ], + "collection_relationships": {} + }`) + res, err := http.Post(fmt.Sprintf("%s/mutation", testServer.URL), "application/json", bytes.NewBuffer(addPetOidcBody)) + assert.NilError(t, err) + assertHTTPResponse(t, res, http.StatusOK, schema.MutationResponse{ + OperationResults: []schema.MutationOperationResults{ + schema.NewProcedureResult(map[string]any{ + "headers": map[string]any{ + "Content-Type": string("application/json"), + }, + "response": map[string]any{}, + }).Encode(), + }, + }) + }) + t.Run("retry", func(t *testing.T) { reqBody := []byte(`{ "collection": "petRetry", @@ -530,7 +618,6 @@ func TestHTTPConnector_distribution(t *testing.T) { assertHTTPResponse(t, res, http.StatusOK, schema.QueryResponse{ { Rows: []map[string]any{ - {"__value": map[string]any{ "errors": []any{}, "results": []any{ @@ -694,6 +781,7 @@ func TestHTTPConnector_multiSchemas(t *testing.T) { testServer := connServer.BuildTestServer() defer testServer.Close() + slog.SetLogLoggerLevel(slog.LevelDebug) reqBody := []byte(`{ "collection": "findCats", "query": { @@ -840,6 +928,66 @@ func createMockServer(t *testing.T, apiKey string, bearerToken string) *httptest } }) + mux.HandleFunc("/pet/oauth", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + authToken := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + if authToken == "" { + t.Errorf("empty Authorization token") + t.FailNow() + + return + } + + tokenBody := "token=" + authToken + tokenResp, err := http.DefaultClient.Post("http://localhost:4445/admin/oauth2/introspect", rest.ContentTypeFormURLEncoded, bytes.NewBufferString(tokenBody)) + assert.NilError(t, err) + assert.Equal(t, http.StatusOK, tokenResp.StatusCode) + + var result struct { + Active bool `json:"active"` + CLientID string `json:"client_id"` + } + + assert.NilError(t, json.NewDecoder(tokenResp.Body).Decode(&result)) + assert.Equal(t, "test-client", result.CLientID) + assert.Equal(t, true, result.Active) + + writeResponse(w, "{}") + default: + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + }) + + mux.HandleFunc("/pet/cookie", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + authCookie, err := r.Cookie("auth") + assert.NilError(t, err) + assert.Equal(t, "auth_token", authCookie.Value) + writeResponse(w, "{}") + default: + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + }) + + mux.HandleFunc("/pet/oidc", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + if r.Header.Get("Authorization") != "Bearer random_token" { + t.Errorf("invalid bearer token, expected: `Authorization: Bearer random_token`, got %s", r.Header.Get("Authorization")) + t.FailNow() + return + } + writeResponse(w, "{}") + default: + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + }) + return httptest.NewServer(mux) } @@ -914,9 +1062,10 @@ func (mds *mockMultiSchemaServer) createServer() *httptest.Server { return func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: - log.Println("headers", r.Header) if r.Header.Get("pet") != name { + slog.Error(fmt.Sprintf("expected r.Header.Get(\"pet\") == %s, got %s", name, r.Header.Get("pet"))) w.WriteHeader(http.StatusBadRequest) + return } writeResponse(w, []byte(fmt.Sprintf(`{"name": "%s"}`, name))) @@ -999,5 +1148,78 @@ func assertHTTPResponse[B any](t *testing.T, res *http.Response, statusCode int, t.Errorf("failed to decode json body, got error: %s; body: %s", err, string(bodyBytes)) } + log.Println(string(bodyBytes)) assert.DeepEqual(t, expectedBody, body) } + +func TestOAuth(t *testing.T) { + apiKey := "random_api_key" + bearerToken := "random_bearer_token" + oauth2ClientID := "test-client" + oauth2ClientSecret := "randomsecret" + createClientBody := []byte(fmt.Sprintf(`{ + "client_id": "%s", + "client_name": "Test client", + "client_secret": "%s", + "audience": ["http://hasura.io"], + "grant_types": ["client_credentials"], + "response_types": ["code"], + "scope": "openid read:pets write:pets", + "token_endpoint_auth_method": "client_secret_post" + }`, oauth2ClientID, oauth2ClientSecret)) + + oauthResp, err := http.DefaultClient.Post("http://localhost:4445/admin/clients", "application/json", bytes.NewBuffer(createClientBody)) + assert.NilError(t, err) + defer oauthResp.Body.Close() + + if oauthResp.StatusCode != http.StatusCreated && oauthResp.StatusCode != http.StatusConflict { + body, _ := io.ReadAll(oauthResp.Body) + t.Fatal(string(body)) + } + + server := createMockServer(t, apiKey, bearerToken) + defer server.Close() + + t.Setenv("PET_STORE_URL", server.URL) + t.Setenv("PET_STORE_API_KEY", apiKey) + t.Setenv("PET_STORE_BEARER_TOKEN", bearerToken) + t.Setenv("OAUTH2_CLIENT_ID", oauth2ClientID) + t.Setenv("OAUTH2_CLIENT_SECRET", oauth2ClientSecret) + connServer, err := connector.NewServer(NewHTTPConnector(), &connector.ServerOptions{ + Configuration: "testdata/auth", + }, connector.WithoutRecovery()) + assert.NilError(t, err) + testServer := connServer.BuildTestServer() + defer testServer.Close() + + findPetsBody := []byte(`{ + "collection": "findPetsOAuth", + "query": { + "fields": { + "__value": { + "type": "column", + "column": "__value" + } + } + }, + "arguments": {}, + "collection_relationships": {} + }`) + + res, err := http.Post(fmt.Sprintf("%s/query", testServer.URL), "application/json", bytes.NewBuffer(findPetsBody)) + assert.NilError(t, err) + assertHTTPResponse(t, res, http.StatusOK, schema.QueryResponse{ + { + Rows: []map[string]any{ + { + "__value": map[string]any{ + "headers": map[string]any{ + "Content-Type": string("application/json"), + }, + "response": map[string]any{}, + }, + }, + }, + }, + }) +} diff --git a/connector/internal/auth/api_key.go b/connector/internal/auth/api_key.go new file mode 100644 index 0000000..5666345 --- /dev/null +++ b/connector/internal/auth/api_key.go @@ -0,0 +1,74 @@ +package auth + +import ( + "fmt" + "net/http" + + "github.com/hasura/ndc-http/ndc-http-schema/schema" + "github.com/hasura/ndc-http/ndc-http-schema/utils" +) + +// APIKeyCredential presents an API key credential +type ApiKeyCredential struct { + In schema.APIKeyLocation + Name string + Value string + + client *http.Client +} + +// NewApiKeyCredential creates a new APIKeyCredential instance. +func NewApiKeyCredential(client *http.Client, config *schema.APIKeyAuthConfig) (*ApiKeyCredential, error) { + value, err := config.Value.Get() + if err != nil { + return nil, fmt.Errorf("failed to create ApiKeyCredential: %w", err) + } + + return &ApiKeyCredential{ + In: config.In, + Name: config.Name, + Value: value, + + client: client, + }, nil +} + +// GetClient gets the HTTP client that is compatible with the current credential. +func (akc ApiKeyCredential) GetClient() *http.Client { + return akc.client +} + +// Inject the credential into the incoming request +func (akc ApiKeyCredential) Inject(req *http.Request) (bool, error) { + if akc.Value == "" { + return false, nil + } + + akc.inject(req, akc.Value) + + return true, nil +} + +// InjectMock injects the mock credential into the incoming request for explain APIs. +func (akc ApiKeyCredential) InjectMock(req *http.Request) bool { + if akc.Value == "" { + return false + } + + akc.inject(req, utils.MaskString(akc.Value)) + + return true +} + +func (akc ApiKeyCredential) inject(req *http.Request, value string) { + switch akc.In { + case schema.APIKeyInHeader: + req.Header.Set(akc.Name, value) + case schema.APIKeyInQuery: + endpoint := req.URL + q := endpoint.Query() + q.Add(akc.Name, value) + endpoint.RawQuery = q.Encode() + req.URL = endpoint + } +} diff --git a/connector/internal/auth/auth.go b/connector/internal/auth/auth.go new file mode 100644 index 0000000..0435fa6 --- /dev/null +++ b/connector/internal/auth/auth.go @@ -0,0 +1,87 @@ +package auth + +import ( + "context" + "errors" + "net/http" + + "github.com/hasura/ndc-http/ndc-http-schema/schema" +) + +// Credential abstracts an authentication credential interface. +type Credential interface { + // GetClient gets the HTTP client that is compatible with the current credential. + GetClient() *http.Client + // Inject the credential into the incoming request. + Inject(request *http.Request) (bool, error) + // InjectMock injects the mock credential into the incoming request for explain APIs. + InjectMock(request *http.Request) bool +} + +// NewCredential creates a generic credential from the security scheme. +func NewCredential(ctx context.Context, httpClient *http.Client, security schema.SecurityScheme) (Credential, bool, error) { + if security.SecuritySchemer == nil { + return nil, false, errors.New("empty security scheme") + } + + switch ss := security.SecuritySchemer.(type) { + case *schema.APIKeyAuthConfig: + cred, err := NewApiKeyCredential(httpClient, ss) + + return cred, err == nil, err + case *schema.BasicAuthConfig: + cred, err := NewBasicCredential(httpClient, ss) + + return cred, err == nil, err + case *schema.HTTPAuthConfig: + cred, err := NewHTTPCredential(httpClient, ss) + + return cred, err == nil, err + case *schema.OAuth2Config: + var headerForwardingRequired bool + for flowType, flow := range ss.Flows { + if flowType != schema.ClientCredentialsFlow { + headerForwardingRequired = true + } + + cred, err := NewOAuth2Client(ctx, httpClient, flowType, &flow) + + return cred, headerForwardingRequired || err != nil, err + } + case *schema.CookieAuthConfig: + cred, err := NewCookieCredential(httpClient) + + return cred, true, err + } + + return NewMockCredential(httpClient), true, nil +} + +// MockCredential implements a mock credential. +type MockCredential struct { + client *http.Client +} + +var _ Credential = &MockCredential{} + +// NewMockCredential creates a new MockCredential instance. +func NewMockCredential(client *http.Client) *MockCredential { + return &MockCredential{ + client: client, + } +} + +// GetClient gets the HTTP client that is compatible with the current credential. +func (cc MockCredential) GetClient() *http.Client { + return cc.client +} + +// Inject the credential into the incoming request +func (cc MockCredential) Inject(req *http.Request) (bool, error) { + return false, nil +} + +// InjectMock injects the mock credential into the incoming request for explain APIs. +func (cc MockCredential) InjectMock(req *http.Request) bool { + return false +} diff --git a/connector/internal/auth/basic.go b/connector/internal/auth/basic.go new file mode 100644 index 0000000..abc36a2 --- /dev/null +++ b/connector/internal/auth/basic.go @@ -0,0 +1,80 @@ +package auth + +import ( + "encoding/base64" + "fmt" + "net/http" + "net/url" + + "github.com/hasura/ndc-http/ndc-http-schema/schema" +) + +// BasicCredential represents the basic authentication credential +type BasicCredential struct { + UserInfo *url.Userinfo + Header string + + client *http.Client +} + +var _ Credential = &BasicCredential{} + +// NewBasicCredential creates a new BasicCredential instance. +func NewBasicCredential(client *http.Client, config *schema.BasicAuthConfig) (*BasicCredential, error) { + user, err := config.Username.Get() + if err != nil { + return nil, fmt.Errorf("BasicAuthConfig.Username: %w", err) + } + + password, err := config.Password.Get() + if err != nil { + return nil, fmt.Errorf("BasicAuthConfig.Password: %w", err) + } + + result := &BasicCredential{ + client: client, + } + + if password != "" { + result.UserInfo = url.UserPassword(user, password) + } else { + result.UserInfo = url.User(user) + } + + return result, nil +} + +// GetClient gets the HTTP client that is compatible with the current credential. +func (bc BasicCredential) GetClient() *http.Client { + return bc.client +} + +// Inject the credential into the incoming request +func (bc BasicCredential) Inject(req *http.Request) (bool, error) { + if bc.UserInfo == nil { + return false, nil + } + + return bc.inject(req, *bc.UserInfo) +} + +// InjectMock injects the mock credential into the incoming request for explain APIs. +func (bc BasicCredential) InjectMock(req *http.Request) bool { + if bc.UserInfo == nil { + return false + } + _, _ = bc.inject(req, *url.UserPassword("xxx", "xxx")) + + return true +} + +func (bc BasicCredential) inject(req *http.Request, userInfo url.Userinfo) (bool, error) { + if bc.Header != "" { + b64Value := base64.StdEncoding.EncodeToString([]byte(userInfo.String())) + req.Header.Set(bc.Header, "Basic "+b64Value) + } else { + req.URL.User = &userInfo + } + + return true, nil +} diff --git a/connector/internal/auth/http.go b/connector/internal/auth/http.go new file mode 100644 index 0000000..d46fc6f --- /dev/null +++ b/connector/internal/auth/http.go @@ -0,0 +1,104 @@ +package auth + +import ( + "fmt" + "net/http" + + "github.com/hasura/ndc-http/ndc-http-schema/schema" + "github.com/hasura/ndc-http/ndc-http-schema/utils" +) + +// HTTPCredential presents a header authentication credential +type HTTPCredential struct { + Header string + Scheme string + Value string + + client *http.Client +} + +var _ Credential = &HTTPCredential{} + +// NewHTTPCredential creates a new HTTPCredential instance. +func NewHTTPCredential(client *http.Client, config *schema.HTTPAuthConfig) (*HTTPCredential, error) { + value, err := config.Value.Get() + if err != nil { + return nil, fmt.Errorf("failed to create ApiKeyCredential: %w", err) + } + + return &HTTPCredential{ + Header: config.Header, + Scheme: config.Scheme, + Value: value, + client: client, + }, nil +} + +// GetClient gets the HTTP client that is compatible with the current credential. +func (hc HTTPCredential) GetClient() *http.Client { + return hc.client +} + +// Inject the credential into the incoming request +func (hc HTTPCredential) Inject(req *http.Request) (bool, error) { + if hc.Value == "" { + return false, nil + } + + hc.inject(req, hc.Value) + + return true, nil +} + +// InjectMock injects the mock credential into the incoming request for explain APIs. +func (hc HTTPCredential) InjectMock(req *http.Request) bool { + if hc.Value == "" { + return false + } + + hc.inject(req, utils.MaskString(hc.Value)) + + return true +} + +func (hc HTTPCredential) inject(req *http.Request, value string) { + headerName := hc.Header + if headerName == "" { + headerName = schema.AuthorizationHeader + } + scheme := hc.Scheme + if scheme == "bearer" { + scheme = "Bearer" + } + + req.Header.Set(headerName, scheme+" "+value) +} + +// CookieCredential presents a cookie credential +type CookieCredential struct { + client *http.Client +} + +var _ Credential = &CookieCredential{} + +// NewCookieCredential creates a new CookieCredential instance. +func NewCookieCredential(client *http.Client) (*CookieCredential, error) { + return &CookieCredential{ + client: client, + }, nil +} + +// GetClient gets the HTTP client that is compatible with the current credential. +func (cc CookieCredential) GetClient() *http.Client { + return cc.client +} + +// Inject the credential into the incoming request +func (cc CookieCredential) Inject(req *http.Request) (bool, error) { + return false, nil +} + +// InjectMock injects the mock credential into the incoming request for explain APIs. +func (cc CookieCredential) InjectMock(req *http.Request) bool { + return false +} diff --git a/connector/internal/auth/oauth2.go b/connector/internal/auth/oauth2.go new file mode 100644 index 0000000..473f5e4 --- /dev/null +++ b/connector/internal/auth/oauth2.go @@ -0,0 +1,93 @@ +package auth + +import ( + "context" + "fmt" + "net/http" + "net/url" + + "github.com/hasura/ndc-http/ndc-http-schema/schema" + "golang.org/x/oauth2" + "golang.org/x/oauth2/clientcredentials" +) + +// OAuth2Client represent the client of the OAuth2 client credentials +type OAuth2Client struct { + client *http.Client +} + +var _ Credential = &OAuth2Client{} + +// NewOAuth2Client creates an OAuth2 client from the security scheme +func NewOAuth2Client(ctx context.Context, httpClient *http.Client, flowType schema.OAuthFlowType, config *schema.OAuthFlow) (*OAuth2Client, error) { + if flowType != schema.ClientCredentialsFlow { + return &OAuth2Client{ + client: httpClient, + }, nil + } + + tokenURL, err := config.TokenURL.Get() + if err != nil { + return nil, fmt.Errorf("tokenUrl: %w", err) + } + + if _, err := schema.ParseRelativeOrHttpURL(tokenURL); err != nil { + return nil, fmt.Errorf("tokenUrl: %w", err) + } + + scopes := make([]string, 0, len(config.Scopes)) + for scope := range config.Scopes { + scopes = append(scopes, scope) + } + + clientID, err := config.ClientID.Get() + if err != nil { + return nil, fmt.Errorf("clientId: %w", err) + } + + clientSecret, err := config.ClientSecret.Get() + if err != nil { + return nil, fmt.Errorf("clientSecret: %w", err) + } + + var endpointParams url.Values + for key, envValue := range config.EndpointParams { + value, err := envValue.Get() + if err != nil { + return nil, fmt.Errorf("endpointParams[%s]: %w", key, err) + } + endpointParams.Set(key, value) + } + + ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient) + conf := &clientcredentials.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + Scopes: scopes, + TokenURL: tokenURL, + EndpointParams: endpointParams, + } + + client := conf.Client(ctx) + + return &OAuth2Client{ + client: client, + }, nil +} + +// GetClient gets the HTTP client that is compatible with the current credential. +func (oc OAuth2Client) GetClient() *http.Client { + return oc.client +} + +// Inject the credential into the incoming request +func (oc OAuth2Client) Inject(req *http.Request) (bool, error) { + return true, nil +} + +// InjectMock injects the mock credential into the incoming request for explain APIs. +func (oc OAuth2Client) InjectMock(req *http.Request) bool { + req.Header.Set(schema.AuthorizationHeader, "Bearer xxx") + + return true +} diff --git a/connector/internal/client.go b/connector/internal/client.go index 72f86d9..a979e69 100644 --- a/connector/internal/client.go +++ b/connector/internal/client.go @@ -18,8 +18,10 @@ import ( "sync" "time" + "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/connector" "github.com/hasura/ndc-sdk-go/schema" "github.com/hasura/ndc-sdk-go/utils" @@ -31,26 +33,21 @@ import ( "golang.org/x/sync/errgroup" ) -// Doer abstracts a HTTP client with Do method -type Doer interface { - Do(req *http.Request) (*http.Response, error) -} - // HTTPClient represents a http client wrapper with advanced methods type HTTPClient struct { - client Doer - schema *rest.NDCHttpSchema + manager *UpstreamManager + metadata *configuration.NDCHttpRuntimeSchema forwardHeaders configuration.ForwardHeadersSettings tracer *connector.Tracer propagator propagation.TextMapPropagator } // NewHTTPClient creates a http client wrapper -func NewHTTPClient(client Doer, httpSchema *rest.NDCHttpSchema, forwardHeaders configuration.ForwardHeadersSettings, tracer *connector.Tracer) *HTTPClient { +func NewHTTPClient(upstreams *UpstreamManager, metadata *configuration.NDCHttpRuntimeSchema, forwardHeaders configuration.ForwardHeadersSettings, tracer *connector.Tracer) *HTTPClient { return &HTTPClient{ - client: client, + manager: upstreams, tracer: tracer, - schema: httpSchema, + metadata: metadata, forwardHeaders: forwardHeaders, propagator: otel.GetTextMapPropagator(), } @@ -63,13 +60,13 @@ func (client *HTTPClient) SetTracer(tracer *connector.Tracer) { // Send creates and executes the request and evaluate response selection func (client *HTTPClient) Send(ctx context.Context, request *RetryableRequest, selection schema.NestedField, resultType schema.Type, httpOptions *HTTPOptions) (any, http.Header, error) { - requests, err := BuildDistributedRequestsWithOptions(request, httpOptions) + requests, err := client.manager.BuildDistributedRequestsWithOptions(request, httpOptions) if err != nil { return nil, nil, err } if !httpOptions.Distributed { - result, headers, err := client.sendSingle(ctx, &requests[0], selection, resultType, requests[0].ServerID, "single") + result, headers, err := client.sendSingle(ctx, &requests[0], selection, resultType, "single") if err != nil { return nil, nil, err } @@ -94,7 +91,7 @@ func (client *HTTPClient) sendSequence(ctx context.Context, requests []Retryable var firstHeaders http.Header for _, req := range requests { - result, headers, err := client.sendSingle(ctx, &req, selection, resultType, req.ServerID, "sequence") + result, headers, err := client.sendSingle(ctx, &req, selection, resultType, "sequence") if err != nil { results.Errors = append(results.Errors, DistributedError{ Server: req.ServerID, @@ -127,7 +124,7 @@ func (client *HTTPClient) sendParallel(ctx context.Context, requests []Retryable sendFunc := func(req RetryableRequest) { eg.Go(func() error { - result, headers, err := client.sendSingle(ctx, &req, selection, resultType, req.ServerID, "parallel") + result, headers, err := client.sendSingle(ctx, &req, selection, resultType, "parallel") lock.Lock() defer lock.Unlock() if err != nil { @@ -159,8 +156,8 @@ func (client *HTTPClient) sendParallel(ctx context.Context, requests []Retryable } // execute a request to the remote server with retries -func (client *HTTPClient) sendSingle(ctx context.Context, request *RetryableRequest, selection schema.NestedField, resultType schema.Type, serverID string, mode string) (any, http.Header, *schema.ConnectorError) { - ctx, span := client.tracer.Start(ctx, "Send Request to Server "+serverID) +func (client *HTTPClient) sendSingle(ctx context.Context, request *RetryableRequest, selection schema.NestedField, resultType schema.Type, mode string) (any, http.Header, *schema.ConnectorError) { + ctx, span := client.tracer.Start(ctx, "Send Request to Server "+request.ServerID) defer span.End() span.SetAttributes(attribute.String("execution.mode", mode)) @@ -176,8 +173,6 @@ func (client *HTTPClient) sendSingle(ctx context.Context, request *RetryableRequ port = 443 } - client.propagator.Inject(ctx, propagation.HeaderCarrier(request.Headers)) - logger := connector.GetLogger(ctx) if logger.Enabled(ctx, slog.LevelDebug) { logAttrs := []any{ @@ -239,7 +234,7 @@ func (client *HTTPClient) sendSingle(ctx context.Context, request *RetryableRequ details["error"] = string(errorBytes) } case rest.ContentTypeXML: - errData, err := decodeArbitraryXML(bytes.NewReader(errorBytes)) + errData, err := contenttype.DecodeArbitraryXML(bytes.NewReader(errorBytes)) if err != nil { details["error"] = string(errorBytes) } else { @@ -273,9 +268,9 @@ func (client *HTTPClient) doRequest(ctx context.Context, request *RetryableReque urlAttr := cloneURL(&request.URL) password, hasPassword := urlAttr.User.Password() if urlAttr.User.String() != "" || hasPassword { - maskedUser := MaskString(urlAttr.User.Username()) + maskedUser := restUtils.MaskString(urlAttr.User.Username()) if hasPassword { - urlAttr.User = url.UserPassword(maskedUser, MaskString(password)) + urlAttr.User = url.UserPassword(maskedUser, restUtils.MaskString(password)) } else { urlAttr.User = url.User(maskedUser) } @@ -300,19 +295,10 @@ func (client *HTTPClient) doRequest(ctx context.Context, request *RetryableReque client.propagator.Inject(ctx, propagation.HeaderCarrier(request.Headers)) - req, cancel, err := request.CreateRequest(ctx) - if err != nil { - span.SetStatus(codes.Error, "error happened when creating request") - span.RecordError(err) - - return nil, nil, nil, err - } - - resp, err := client.client.Do(req) + resp, cancel, err := client.manager.ExecuteRequest(ctx, request, client.metadata.Name) if err != nil { span.SetStatus(codes.Error, "error happened when executing the request") span.RecordError(err) - cancel() return nil, nil, nil, err } @@ -371,7 +357,7 @@ func (client *HTTPClient) evalHTTPResponse(ctx context.Context, span trace.Span, return true, resp.Header, nil } - if resp.Body == nil { + if resp.Body == nil || resp.ContentLength == 0 { return nil, resp.Header, nil } @@ -390,7 +376,7 @@ func (client *HTTPClient) evalHTTPResponse(ctx context.Context, span trace.Span, return nil, nil, schema.NewConnectorError(http.StatusInternalServerError, "failed to extract forwarded headers response: "+err.Error(), nil) } - result, err = NewXMLDecoder(client.schema).Decode(resp.Body, field) + result, err = contenttype.NewXMLDecoder(client.metadata.NDCHttpSchema).Decode(resp.Body, field) if err != nil { return nil, nil, schema.NewConnectorError(http.StatusInternalServerError, err.Error(), nil) } @@ -475,7 +461,7 @@ func (client *HTTPClient) extractForwardedHeadersResultType(resultType schema.Ty case *schema.ArrayType: return nil, errors.New("expected object type, got array") case *schema.NamedType: - objectType, ok := client.schema.ObjectTypes[t.Name] + objectType, ok := client.metadata.NDCHttpSchema.ObjectTypes[t.Name] if !ok { return nil, fmt.Errorf("%s: expected object type", t.Name) } diff --git a/connector/internal/decode.go b/connector/internal/contenttype/data_uri.go similarity index 98% rename from connector/internal/decode.go rename to connector/internal/contenttype/data_uri.go index 77e3656..d19b198 100644 --- a/connector/internal/decode.go +++ b/connector/internal/contenttype/data_uri.go @@ -1,4 +1,4 @@ -package internal +package contenttype import ( "encoding/base64" diff --git a/connector/internal/decode_test.go b/connector/internal/contenttype/data_uri_test.go similarity index 98% rename from connector/internal/decode_test.go rename to connector/internal/contenttype/data_uri_test.go index 81c4ff1..88c98f6 100644 --- a/connector/internal/decode_test.go +++ b/connector/internal/contenttype/data_uri_test.go @@ -1,4 +1,4 @@ -package internal +package contenttype import ( "testing" diff --git a/connector/internal/multipart.go b/connector/internal/contenttype/multipart.go similarity index 99% rename from connector/internal/multipart.go rename to connector/internal/contenttype/multipart.go index a224d5f..ee5849d 100644 --- a/connector/internal/multipart.go +++ b/connector/internal/contenttype/multipart.go @@ -1,4 +1,4 @@ -package internal +package contenttype import ( "encoding/json" diff --git a/connector/internal/contenttype/utils.go b/connector/internal/contenttype/utils.go new file mode 100644 index 0000000..362dad6 --- /dev/null +++ b/connector/internal/contenttype/utils.go @@ -0,0 +1,27 @@ +package contenttype + +import ( + "fmt" + "reflect" + "strconv" +) + +// StringifySimpleScalar converts a simple scalar value to string. +func StringifySimpleScalar(val reflect.Value, kind reflect.Kind) (string, error) { + switch kind { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return strconv.FormatInt(val.Int(), 10), nil + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + return strconv.FormatUint(val.Uint(), 10), nil + case reflect.Float32, reflect.Float64: + return strconv.FormatFloat(val.Float(), 'g', -1, val.Type().Bits()), nil + case reflect.String: + return val.String(), nil + case reflect.Bool: + return strconv.FormatBool(val.Bool()), nil + case reflect.Interface: + return fmt.Sprint(val.Interface()), nil + default: + return "", fmt.Errorf("invalid value: %v", val.Interface()) + } +} diff --git a/connector/internal/xml_decode.go b/connector/internal/contenttype/xml_decode.go similarity index 88% rename from connector/internal/xml_decode.go rename to connector/internal/contenttype/xml_decode.go index 5389930..7ceda71 100644 --- a/connector/internal/xml_decode.go +++ b/connector/internal/contenttype/xml_decode.go @@ -1,4 +1,4 @@ -package internal +package contenttype import ( "encoding/json" @@ -434,7 +434,8 @@ L: return nil } -func decodeArbitraryXML(r io.Reader) (any, error) { +// DecodeArbitraryXML decodes an arbitrary XML from a reader stream. +func DecodeArbitraryXML(r io.Reader) (any, error) { decoder := xml.NewDecoder(r) for { @@ -460,3 +461,61 @@ func decodeArbitraryXML(r io.Reader) (any, error) { return nil, nil } + +func findXMLLeafObjectField(objectType rest.ObjectType) (*rest.ObjectField, string, bool) { + var f *rest.ObjectField + var fieldName string + for key, field := range objectType.Fields { + if field.HTTP == nil || field.HTTP.XML == nil { + return nil, "", false + } + if field.HTTP.XML.Text { + f = &field + fieldName = key + } else if !field.HTTP.XML.Attribute { + return nil, "", false + } + } + + return f, fieldName, true +} + +func getTypeSchemaXMLName(typeSchema *rest.TypeSchema, defaultName string) string { + if typeSchema != nil { + return getXMLName(typeSchema.XML, defaultName) + } + + return defaultName +} + +func getXMLName(xmlSchema *rest.XMLSchema, defaultName string) string { + if xmlSchema != nil { + if xmlSchema.Name != "" { + return xmlSchema.GetFullName() + } + + if xmlSchema.Prefix != "" { + return xmlSchema.Prefix + ":" + defaultName + } + } + + return defaultName +} + +func getArrayOrNamedType(schemaType schema.Type) (*schema.ArrayType, *schema.NamedType, error) { + rawType, err := schemaType.InterfaceT() + if err != nil { + return nil, nil, err + } + + switch t := rawType.(type) { + case *schema.NullableType: + return getArrayOrNamedType(t.UnderlyingType) + case *schema.ArrayType: + return t, nil, nil + case *schema.NamedType: + return nil, t, nil + default: + return nil, nil, nil + } +} diff --git a/connector/internal/xml_decode_test.go b/connector/internal/contenttype/xml_decode_test.go similarity index 86% rename from connector/internal/xml_decode_test.go rename to connector/internal/contenttype/xml_decode_test.go index a2a21fd..dc93ece 100644 --- a/connector/internal/xml_decode_test.go +++ b/connector/internal/contenttype/xml_decode_test.go @@ -1,9 +1,12 @@ -package internal +package contenttype import ( + "encoding/json" + "os" "strings" "testing" + rest "github.com/hasura/ndc-http/ndc-http-schema/schema" "github.com/hasura/ndc-sdk-go/schema" "gotest.tools/v3/assert" ) @@ -68,3 +71,12 @@ func TestDecodeXML(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/xml_encode.go b/connector/internal/contenttype/xml_encode.go similarity index 98% rename from connector/internal/xml_encode.go rename to connector/internal/contenttype/xml_encode.go index 14dc64e..f10f769 100644 --- a/connector/internal/xml_encode.go +++ b/connector/internal/contenttype/xml_encode.go @@ -1,4 +1,4 @@ -package internal +package contenttype import ( "bytes" @@ -280,7 +280,7 @@ func (c *XMLEncoder) encodeXMLText(schemaType schema.Type, value reflect.Value, } if _, ok := c.schema.ScalarTypes[t.Name]; ok { - str, err := stringifySimpleScalar(value, value.Kind()) + str, err := StringifySimpleScalar(value, value.Kind()) if err != nil { return nil, err } @@ -302,7 +302,7 @@ 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()) + str, err := StringifySimpleScalar(value, value.Kind()) if err != nil { return err } diff --git a/connector/internal/xml_encode_test.go b/connector/internal/contenttype/xml_encode_test.go similarity index 99% rename from connector/internal/xml_encode_test.go rename to connector/internal/contenttype/xml_encode_test.go index 783c38d..7434471 100644 --- a/connector/internal/xml_encode_test.go +++ b/connector/internal/contenttype/xml_encode_test.go @@ -1,4 +1,4 @@ -package internal +package contenttype import ( "bytes" diff --git a/connector/internal/request.go b/connector/internal/request.go index ca541f0..fdabad4 100644 --- a/connector/internal/request.go +++ b/connector/internal/request.go @@ -1,15 +1,10 @@ package internal import ( - "bytes" "context" - "encoding/base64" - "fmt" "io" - "math/rand/v2" "net/http" "net/url" - "slices" "strings" "time" @@ -20,6 +15,7 @@ import ( type RetryableRequest struct { RawRequest *rest.Request URL url.URL + Namespace string ServerID string ContentType string ContentLength int64 @@ -56,247 +52,3 @@ func (r *RetryableRequest) CreateRequest(ctx context.Context) (*http.Request, co return request, cancel, nil } - -func getBaseURLFromServers(servers []rest.ServerConfig, serverIDs []string) (*url.URL, string) { - var results []url.URL - var selectedServerIDs []string - for _, server := range servers { - if len(serverIDs) > 0 && !slices.Contains(serverIDs, server.ID) { - continue - } - hostPtr, err := server.GetURL() - if err == nil { - results = append(results, hostPtr) - selectedServerIDs = append(selectedServerIDs, server.ID) - } - } - - switch len(results) { - case 0: - return nil, "" - case 1: - result := results[0] - - return &result, selectedServerIDs[0] - default: - index := rand.IntN(len(results) - 1) - host := results[index] - - return &host, selectedServerIDs[index] - } -} - -// BuildDistributedRequestsWithOptions builds distributed requests with options -func BuildDistributedRequestsWithOptions(request *RetryableRequest, httpOptions *HTTPOptions) ([]RetryableRequest, error) { - if strings.HasPrefix(request.URL.Scheme, "http") { - return []RetryableRequest{*request}, nil - } - - if !httpOptions.Distributed || len(httpOptions.Settings.Servers) == 1 { - baseURL, serverID := getBaseURLFromServers(httpOptions.Settings.Servers, httpOptions.Servers) - request.URL.Scheme = baseURL.Scheme - request.URL.Host = baseURL.Host - request.URL.Path = baseURL.Path + request.URL.Path - request.ServerID = serverID - if err := request.applySettings(httpOptions.Settings, httpOptions.Explain); err != nil { - return nil, err - } - - return []RetryableRequest{*request}, nil - } - - var requests []RetryableRequest - var buf []byte - var err error - if httpOptions.Parallel && request.Body != nil { - // copy new readers for each requests to avoid race condition - buf, err = io.ReadAll(request.Body) - if err != nil { - return nil, fmt.Errorf("failed to read request body: %w", err) - } - } - serverIDs := httpOptions.Servers - if len(serverIDs) == 0 { - for _, server := range httpOptions.Settings.Servers { - serverIDs = append(serverIDs, server.ID) - } - } - for _, serverID := range serverIDs { - baseURL, serverID := getBaseURLFromServers(httpOptions.Settings.Servers, []string{serverID}) - if baseURL == nil { - continue - } - baseURL.Path += request.URL.Path - baseURL.RawQuery = request.URL.RawQuery - baseURL.Fragment = request.URL.Fragment - req := RetryableRequest{ - URL: *baseURL, - ServerID: serverID, - RawRequest: request.RawRequest, - ContentType: request.ContentType, - Headers: request.Headers.Clone(), - Body: request.Body, - } - if err := req.applySettings(httpOptions.Settings, httpOptions.Explain); err != nil { - return nil, err - } - if len(buf) > 0 { - req.Body = bytes.NewReader(buf) - } - requests = append(requests, req) - } - - return requests, nil -} - -func (req *RetryableRequest) getServerConfig(settings *rest.NDCHttpSettings) *rest.ServerConfig { - if settings == nil { - return nil - } - if req.ServerID == "" { - return &settings.Servers[0] - } - for _, server := range settings.Servers { - if server.ID == req.ServerID { - return &server - } - } - - return nil -} - -func (req *RetryableRequest) applySecurity(serverConfig *rest.ServerConfig, isExplain bool) error { - if serverConfig == nil { - return nil - } - - securitySchemes := serverConfig.SecuritySchemes - securities := req.RawRequest.Security - if req.RawRequest.Security.IsEmpty() && serverConfig.Security != nil { - securities = serverConfig.Security - } - - if securities.IsOptional() || len(securitySchemes) == 0 { - return nil - } - - for _, security := range securities { - sc, ok := securitySchemes[security.Name()] - if !ok { - continue - } - - hasAuth, err := req.applySecurityScheme(sc, isExplain) - if hasAuth || err != nil { - return err - } - } - - return nil -} - -func (req *RetryableRequest) applySecurityScheme(securityScheme rest.SecurityScheme, isExplain bool) (bool, error) { - if securityScheme.SecuritySchemer == nil { - return false, nil - } - - if req.Headers == nil { - req.Headers = http.Header{} - } - - switch config := securityScheme.SecuritySchemer.(type) { - case *rest.BasicAuthConfig: - username := config.GetUsername() - password := config.GetPassword() - if config.Header != "" { - b64Value := base64.StdEncoding.EncodeToString([]byte(username + ":" + password)) - req.Headers.Set(rest.AuthorizationHeader, "Basic "+b64Value) - } else { - req.URL.User = url.UserPassword(username, password) - } - - return true, nil - case *rest.HTTPAuthConfig: - headerName := config.Header - if headerName == "" { - headerName = rest.AuthorizationHeader - } - scheme := config.Scheme - if scheme == "bearer" { - scheme = "Bearer" - } - v := config.GetValue() - if v != "" { - req.Headers.Set(headerName, fmt.Sprintf("%s %s", scheme, eitherMaskSecret(v, isExplain))) - - return true, nil - } - case *rest.APIKeyAuthConfig: - switch config.In { - case rest.APIKeyInHeader: - value := config.GetValue() - if value != "" { - req.Headers.Set(config.Name, eitherMaskSecret(value, isExplain)) - - return true, nil - } - case rest.APIKeyInQuery: - value := config.GetValue() - if value != "" { - endpoint := req.URL - q := endpoint.Query() - q.Add(config.Name, eitherMaskSecret(value, isExplain)) - endpoint.RawQuery = q.Encode() - req.URL = endpoint - - return true, nil - } - case rest.APIKeyInCookie: - // Cookie header should be forwarded from Hasura engine - return true, nil - default: - return false, fmt.Errorf("unsupported location for apiKey scheme: %s", config.In) - } - // TODO: support OAuth and OIDC - // Authentication headers can be forwarded from Hasura engine - case *rest.OAuth2Config, *rest.OpenIDConnectConfig: - case *rest.CookieAuthConfig: - return true, nil - case *rest.MutualTLSAuthConfig: - // the server may require not only mutualTLS authentication - return false, nil - default: - return false, fmt.Errorf("unsupported security scheme: %s", securityScheme.GetType()) - } - - return false, nil -} - -func (req *RetryableRequest) applySettings(settings *rest.NDCHttpSettings, isExplain bool) error { - if settings == nil { - return nil - } - serverConfig := req.getServerConfig(settings) - if serverConfig == nil { - return nil - } - if err := req.applySecurity(serverConfig, isExplain); err != nil { - return err - } - - req.applyDefaultHeaders(serverConfig.GetHeaders()) - req.applyDefaultHeaders(settings.GetHeaders()) - - return nil -} - -func (req *RetryableRequest) applyDefaultHeaders(defaultHeaders map[string]string) { - for k, envValue := range defaultHeaders { - if req.Headers.Get(k) != "" { - continue - } - if envValue != "" { - req.Headers.Set(k, envValue) - } - } -} diff --git a/connector/internal/request_builder.go b/connector/internal/request_builder.go index 4cfa6cd..41e71af 100644 --- a/connector/internal/request_builder.go +++ b/connector/internal/request_builder.go @@ -11,6 +11,7 @@ import ( "slices" "strings" + "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" @@ -98,7 +99,7 @@ func (c *RequestBuilder) buildRequestBody(request *RetryableRequest, rawRequest if err != nil { return err } - dataURI, err := DecodeDataURI(b64) + dataURI, err := contenttype.DecodeDataURI(b64) if err != nil { return err } @@ -144,7 +145,7 @@ func (c *RequestBuilder) buildRequestBody(request *RetryableRequest, rawRequest return nil case contentType == rest.ContentTypeXML: - bodyBytes, err := NewXMLEncoder(c.Schema).Encode(&bodyInfo, bodyData) + bodyBytes, err := contenttype.NewXMLEncoder(c.Schema).Encode(&bodyInfo, bodyData) if err != nil { return err } @@ -200,7 +201,7 @@ func (c *RequestBuilder) createMultipartForm(bodyData any) (*bytes.Reader, strin } buffer := new(bytes.Buffer) - writer := NewMultipartWriter(buffer) + writer := contenttype.NewMultipartWriter(buffer) if err := c.evalMultipartForm(writer, &bodyInfo, reflect.ValueOf(bodyData)); err != nil { return nil, "", err @@ -215,7 +216,7 @@ func (c *RequestBuilder) createMultipartForm(bodyData any) (*bytes.Reader, strin return reader, writer.FormDataContentType(), nil } -func (c *RequestBuilder) evalMultipartForm(w *MultipartWriter, bodyInfo *rest.ArgumentInfo, bodyData reflect.Value) error { +func (c *RequestBuilder) evalMultipartForm(w *contenttype.MultipartWriter, bodyInfo *rest.ArgumentInfo, bodyData reflect.Value) error { bodyData, ok := utils.UnwrapPointerFromReflectValue(bodyData) if !ok { return nil @@ -291,7 +292,7 @@ func (c *RequestBuilder) evalMultipartForm(w *MultipartWriter, bodyInfo *rest.Ar return fmt.Errorf("invalid multipart form body, expected object, got %v", bodyInfo.Type) } -func (c *RequestBuilder) evalMultipartFieldValueRecursive(w *MultipartWriter, name string, value reflect.Value, fieldInfo *rest.ObjectField, enc *rest.EncodingObject) error { +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) { diff --git a/connector/internal/request_parameter.go b/connector/internal/request_parameter.go index 4c566b0..c3f3eea 100644 --- a/connector/internal/request_parameter.go +++ b/connector/internal/request_parameter.go @@ -12,6 +12,7 @@ import ( "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" @@ -287,7 +288,7 @@ func encodeParameterReflectionValues(reflectValue reflect.Value, fieldPaths []st } kind := reflectValue.Kind() - if result, err := stringifySimpleScalar(reflectValue, kind); err == nil { + if result, err := contenttype.StringifySimpleScalar(reflectValue, kind); err == nil { return []ParameterItem{ NewParameterItem([]Key{}, []string{result}), }, nil diff --git a/connector/internal/types.go b/connector/internal/types.go index e5ccf85..4661925 100644 --- a/connector/internal/types.go +++ b/connector/internal/types.go @@ -6,7 +6,6 @@ import ( "fmt" "regexp" - rest "github.com/hasura/ndc-http/ndc-http-schema/schema" "github.com/hasura/ndc-sdk-go/schema" "github.com/hasura/ndc-sdk-go/utils" ) @@ -31,10 +30,9 @@ type HTTPOptions struct { Servers []string `json:"serverIds" yaml:"serverIds"` Parallel bool `json:"parallel" yaml:"parallel"` - Explain bool `json:"-" yaml:"-"` - Distributed bool `json:"-" yaml:"-"` - Concurrency uint `json:"-" yaml:"-"` - Settings *rest.NDCHttpSettings `json:"-" yaml:"-"` + Explain bool `json:"-" yaml:"-"` + Distributed bool `json:"-" yaml:"-"` + Concurrency uint `json:"-" yaml:"-"` } // FromValue parses http execution options from any value diff --git a/connector/internal/upstream.go b/connector/internal/upstream.go new file mode 100644 index 0000000..422c596 --- /dev/null +++ b/connector/internal/upstream.go @@ -0,0 +1,367 @@ +package internal + +import ( + "bytes" + "context" + "fmt" + "io" + "log/slog" + "math/rand/v2" + "net/http" + "net/url" + "path" + "slices" + "strconv" + "strings" + + "github.com/hasura/ndc-http/connector/internal/auth" + "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" + "github.com/hasura/ndc-sdk-go/utils" +) + +// Server contains server settings. +type Server struct { + URL *url.URL + Headers map[string]string + Credentials map[string]auth.Credential + Security rest.AuthSecurities +} + +type UpstreamSetting struct { + servers map[string]Server + headers map[string]string + security rest.AuthSecurities + credentials map[string]auth.Credential +} + +type UpstreamManager struct { + config *configuration.Configuration + defaultClient *http.Client + httpClients map[string]*http.Client + upstreams map[string]UpstreamSetting +} + +func NewUpstreamManager(httpClient *http.Client, config *configuration.Configuration) *UpstreamManager { + return &UpstreamManager{ + config: config, + defaultClient: httpClient, + httpClients: make(map[string]*http.Client), + upstreams: make(map[string]UpstreamSetting), + } +} + +func (sm *UpstreamManager) Register(ctx context.Context, namespace string, rawSettings *rest.NDCHttpSettings) error { + logger := connector.GetLogger(ctx) + httpClient := sm.defaultClient + sm.httpClients[namespace] = httpClient + + settings := UpstreamSetting{ + servers: make(map[string]Server), + security: rawSettings.Security, + headers: sm.getHeadersFromEnv(logger, namespace, rawSettings.Headers), + credentials: sm.registerSecurityCredentials(ctx, httpClient, rawSettings.SecuritySchemes, logger.With(slog.String("namespace", namespace))), + } + + for i, server := range rawSettings.Servers { + serverID := server.ID + if serverID == "" { + serverID = strconv.Itoa(i) + } + + serverURL, err := server.GetURL() + if err != nil { + // Relax the error to allow schema introspection without environment variables setting. + // Moreover, because there are many security schemes the user may use one of them. + logger.Error(fmt.Sprintf("failed to register server %s:%s, %s", namespace, serverID, err)) + + continue + } + + newServer := Server{ + URL: serverURL, + Headers: sm.getHeadersFromEnv(logger, namespace, server.Headers), + Security: server.Security, + Credentials: sm.registerSecurityCredentials(ctx, httpClient, server.SecuritySchemes, logger.With(slog.String("namespace", namespace), slog.String("server_id", serverID))), + } + + settings.servers[serverID] = newServer + } + + sm.upstreams[namespace] = settings + + return nil +} + +func (sm *UpstreamManager) ExecuteRequest(ctx context.Context, request *RetryableRequest, namespace string) (*http.Response, context.CancelFunc, error) { + req, cancel, err := request.CreateRequest(ctx) + if err != nil { + return nil, nil, err + } + + httpClient, err := sm.evalRequestSettings(ctx, request, req, namespace) + if err != nil { + cancel() + + return nil, nil, err + } + + resp, err := httpClient.Do(req) + if err != nil { + cancel() + + return nil, nil, err + } + + return resp, cancel, nil +} + +func (sm *UpstreamManager) evalRequestSettings(ctx context.Context, request *RetryableRequest, req *http.Request, namespace string) (*http.Client, error) { + httpClient, ok := sm.httpClients[namespace] + if !ok { + httpClient = sm.defaultClient + } + + settings, ok := sm.upstreams[namespace] + if !ok { + return httpClient, nil + } + + for key, header := range settings.headers { + req.Header.Set(key, header) + } + + securities := request.RawRequest.Security + if len(securities) == 0 { + securities = settings.security + } + + logger := connector.GetLogger(ctx) + securityOptional := securities.IsOptional() + + var err error + server, ok := settings.servers[request.ServerID] + if ok { + for key, header := range server.Headers { + if header != "" { + req.Header.Set(key, header) + } + } + + if !securityOptional && len(server.Credentials) > 0 { + var hc *http.Client + hc, err = sm.evalSecuritySchemes(req, securities, server.Credentials) + if err != nil { + logger.Error(fmt.Sprintf("failed to evaluate the authentication: %s", err), slog.String("namespace", namespace), slog.String("server_id", request.ServerID)) + } + + if hc != nil { + return hc, nil + } + } + } + + if !securityOptional && len(settings.credentials) > 0 { + hc, err := sm.evalSecuritySchemes(req, securities, settings.credentials) + if err != nil { + logger.Error(fmt.Sprintf("failed to evaluate the authentication: %s", err), slog.String("namespace", namespace)) + + return nil, err + } + + if hc != nil { + return hc, nil + } + } + + return httpClient, nil +} + +func (sm *UpstreamManager) evalSecuritySchemes(req *http.Request, securities rest.AuthSecurities, credentials map[string]auth.Credential) (*http.Client, error) { + for _, security := range securities { + sc, ok := credentials[security.Name()] + if !ok { + continue + } + + hasAuth, err := sc.Inject(req) + if err != nil { + return nil, err + } + + if hasAuth { + return sc.GetClient(), nil + } + } + + return nil, nil +} + +// InjectMockCredential injects mock credential into the request for explain APIs. +func (sm *UpstreamManager) InjectMockRequestSettings(req *http.Request, namespace string, securities rest.AuthSecurities) { + settings, ok := sm.upstreams[namespace] + if !ok { + return + } + + for key, header := range settings.headers { + req.Header.Set(key, header) + } + + if len(securities) == 0 { + securities = settings.security + } + + if securities.IsOptional() || len(settings.credentials) == 0 { + return + } + + for _, security := range securities { + sc, ok := settings.credentials[security.Name()] + if !ok { + continue + } + hasAuth := sc.InjectMock(req) + if hasAuth { + return + } + } +} + +// BuildDistributedRequestsWithOptions builds distributed requests with options +func (sm *UpstreamManager) BuildDistributedRequestsWithOptions(request *RetryableRequest, httpOptions *HTTPOptions) ([]RetryableRequest, error) { + if strings.HasPrefix(request.URL.Scheme, "http") { + return []RetryableRequest{*request}, nil + } + + upstream, ok := sm.upstreams[request.Namespace] + if !ok { + return nil, schema.InternalServerError(fmt.Sprintf("upstream with namespace %s does not exist", request.Namespace), nil) + } + + if len(upstream.servers) == 0 { + return nil, schema.InternalServerError("no available server in the upstream with namespace "+request.Namespace, nil) + } + + if !httpOptions.Distributed || len(upstream.servers) == 1 { + baseURL, serverID, err := sm.getBaseURLFromServers(upstream.servers, request.Namespace, httpOptions.Servers) + if err != nil { + return nil, err + } + + request.URL.Scheme = baseURL.Scheme + request.URL.Host = baseURL.Host + request.URL.Path = path.Join(baseURL.Path, request.URL.Path) + request.ServerID = serverID + + return []RetryableRequest{*request}, nil + } + + var requests []RetryableRequest + var buf []byte + var err error + if httpOptions.Parallel && request.Body != nil { + // copy new readers for each requests to avoid race condition + buf, err = io.ReadAll(request.Body) + if err != nil { + return nil, fmt.Errorf("failed to read request body: %w", err) + } + } + serverIDs := httpOptions.Servers + if len(serverIDs) == 0 { + serverIDs = utils.GetKeys(upstream.servers) + } + + for _, serverID := range serverIDs { + baseURL, serverID, err := sm.getBaseURLFromServers(upstream.servers, request.Namespace, []string{serverID}) + if err != nil { + return nil, err + } + baseURL.Path += request.URL.Path + baseURL.RawQuery = request.URL.RawQuery + baseURL.Fragment = request.URL.Fragment + req := RetryableRequest{ + URL: *baseURL, + ServerID: serverID, + RawRequest: request.RawRequest, + ContentType: request.ContentType, + Headers: request.Headers.Clone(), + Body: request.Body, + } + if len(buf) > 0 { + req.Body = bytes.NewReader(buf) + } + requests = append(requests, req) + } + + return requests, nil +} + +func (sm *UpstreamManager) getHeadersFromEnv(logger *slog.Logger, namespace string, headers map[string]utils.EnvString) map[string]string { + results := make(map[string]string) + for key, header := range headers { + value, err := header.Get() + if err != nil { + logger.Error(err.Error(), slog.String("namespace", namespace), slog.String("header", key)) + } else if value != "" { + results[key] = value + } + } + + return results +} + +func (sm *UpstreamManager) getBaseURLFromServers(servers map[string]Server, namespace string, serverIDs []string) (*url.URL, string, error) { + var results []*url.URL + var selectedServerIDs []string + for key, server := range servers { + if len(serverIDs) > 0 && !slices.Contains(serverIDs, key) { + continue + } + + hostPtr := server.URL + results = append(results, hostPtr) + selectedServerIDs = append(selectedServerIDs, key) + } + + switch len(results) { + case 0: + return nil, "", fmt.Errorf("requested servers %v in the upstream with namespace %s do not exist", serverIDs, namespace) + case 1: + result := results[0] + + return result, selectedServerIDs[0], nil + default: + index := rand.IntN(len(results) - 1) + host := results[index] + + return host, selectedServerIDs[index], nil + } +} + +func (sm *UpstreamManager) registerSecurityCredentials(ctx context.Context, httpClient *http.Client, securitySchemes map[string]rest.SecurityScheme, logger *slog.Logger) map[string]auth.Credential { + credentials := make(map[string]auth.Credential) + + for key, ss := range securitySchemes { + cred, headerForwardRequired, err := auth.NewCredential(ctx, httpClient, ss) + if err != nil { + // Relax the error to allow schema introspection without environment variables setting. + // Moreover, because there are many security schemes the user may use one of them. + logger.Error( + fmt.Sprintf("failed to register security scheme %s, %s", key, err), + slog.String("scheme", key), + ) + + continue + } + + 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)) + } + } + + return credentials +} diff --git a/connector/internal/utils.go b/connector/internal/utils.go index 67428dd..ec1ffff 100644 --- a/connector/internal/utils.go +++ b/connector/internal/utils.go @@ -4,11 +4,9 @@ import ( "fmt" "net/http" "net/url" - "reflect" - "strconv" "strings" - rest "github.com/hasura/ndc-http/ndc-http-schema/schema" + "github.com/hasura/ndc-http/ndc-http-schema/utils" "github.com/hasura/ndc-sdk-go/schema" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" @@ -31,28 +29,6 @@ func UnwrapNullableType(input schema.Type) (schema.TypeEncoder, bool, error) { } } -// either masks the string value for security -func eitherMaskSecret(input string, shouldMask bool) string { - if !shouldMask { - return input - } - - return MaskString(input) -} - -// MaskString masks the string value for security -func MaskString(input string) string { - inputLength := len(input) - switch { - case inputLength < 6: - return strings.Repeat("*", inputLength) - case inputLength < 12: - return input[0:1] + strings.Repeat("*", inputLength-1) - default: - return input[0:3] + strings.Repeat("*", 7) + fmt.Sprintf("(%d)", inputLength) - } -} - func setHeaderAttributes(span trace.Span, prefix string, httpHeaders http.Header) { for key, headers := range httpHeaders { if len(headers) == 0 { @@ -62,7 +38,7 @@ func setHeaderAttributes(span trace.Span, prefix string, httpHeaders http.Header if sensitiveHeaderRegex.MatchString(strings.ToLower(key)) { values = make([]string, len(headers)) for i, header := range headers { - values[i] = MaskString(header) + values[i] = utils.MaskString(header) } } span.SetAttributes(attribute.StringSlice(prefix+strings.ToLower(key), values)) @@ -84,80 +60,3 @@ func cloneURL(input *url.URL) *url.URL { RawFragment: input.RawFragment, } } - -func stringifySimpleScalar(val reflect.Value, kind reflect.Kind) (string, error) { - switch kind { - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - return strconv.FormatInt(val.Int(), 10), nil - case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: - return strconv.FormatUint(val.Uint(), 10), nil - case reflect.Float32, reflect.Float64: - return strconv.FormatFloat(val.Float(), 'g', -1, val.Type().Bits()), nil - case reflect.String: - return val.String(), nil - case reflect.Bool: - return strconv.FormatBool(val.Bool()), nil - case reflect.Interface: - return fmt.Sprint(val.Interface()), nil - default: - return "", fmt.Errorf("invalid value: %v", val.Interface()) - } -} - -func findXMLLeafObjectField(objectType rest.ObjectType) (*rest.ObjectField, string, bool) { - var f *rest.ObjectField - var fieldName string - for key, field := range objectType.Fields { - if field.HTTP == nil || field.HTTP.XML == nil { - return nil, "", false - } - if field.HTTP.XML.Text { - f = &field - fieldName = key - } else if !field.HTTP.XML.Attribute { - return nil, "", false - } - } - - return f, fieldName, true -} - -func getTypeSchemaXMLName(typeSchema *rest.TypeSchema, defaultName string) string { - if typeSchema != nil { - return getXMLName(typeSchema.XML, defaultName) - } - - return defaultName -} - -func getXMLName(xmlSchema *rest.XMLSchema, defaultName string) string { - if xmlSchema != nil { - if xmlSchema.Name != "" { - return xmlSchema.GetFullName() - } - - if xmlSchema.Prefix != "" { - return xmlSchema.Prefix + ":" + defaultName - } - } - - return defaultName -} - -func getArrayOrNamedType(schemaType schema.Type) (*schema.ArrayType, *schema.NamedType, error) { - rawType, err := schemaType.InterfaceT() - if err != nil { - return nil, nil, err - } - - switch t := rawType.(type) { - case *schema.NullableType: - return getArrayOrNamedType(t.UnderlyingType) - case *schema.ArrayType: - return t, nil, nil - case *schema.NamedType: - return nil, t, nil - default: - return nil, nil, nil - } -} diff --git a/connector/mutation.go b/connector/mutation.go index 65e8034..029436b 100644 --- a/connector/mutation.go +++ b/connector/mutation.go @@ -32,12 +32,12 @@ func (c *HTTPConnector) MutationExplain(ctx context.Context, configuration *conf operation := request.Operations[0] switch operation.Type { case schema.MutationOperationProcedure: - httpRequest, _, _, httpOptions, err := c.explainProcedure(&operation) + httpRequest, _, metadata, httpOptions, err := c.explainProcedure(&operation) if err != nil { return nil, err } - return serializeExplainResponse(httpRequest, httpOptions) + return c.serializeExplainResponse(ctx, httpRequest, metadata, httpOptions) default: return nil, schema.BadRequestError(fmt.Sprintf("invalid operation type: %s", operation.Type), nil) } @@ -63,6 +63,7 @@ func (c *HTTPConnector) explainProcedure(operation *schema.MutationOperation) (* if err != nil { return nil, nil, nil, nil, err } + httpRequest.Namespace = metadata.Name if err := c.evalForwardedHeaders(httpRequest, rawArgs); err != nil { return nil, nil, nil, nil, schema.UnprocessableContentError("invalid forwarded headers", map[string]any{ @@ -77,8 +78,6 @@ func (c *HTTPConnector) explainProcedure(operation *schema.MutationOperation) (* }) } - httpOptions.Settings = metadata.Settings - return httpRequest, procedure, &metadata, httpOptions, nil } @@ -139,7 +138,7 @@ func (c *HTTPConnector) execMutationOperation(parentCtx context.Context, state * } httpOptions.Concurrency = c.config.Concurrency.HTTP - client := internal.NewHTTPClient(c.client, metadata.NDCHttpSchema, c.config.ForwardHeaders, state.Tracer) + client := internal.NewHTTPClient(c.upstreams, metadata, c.config.ForwardHeaders, state.Tracer) result, _, err := client.Send(ctx, httpRequest, operation.Fields, procedure.ResultType, httpOptions) if err != nil { span.SetStatus(codes.Error, "failed to execute mutation") diff --git a/connector/query.go b/connector/query.go index 1c6bbe1..3e31b40 100644 --- a/connector/query.go +++ b/connector/query.go @@ -40,12 +40,12 @@ func (c *HTTPConnector) QueryExplain(ctx context.Context, configuration *configu requestVars = []schema.QueryRequestVariablesElem{make(schema.QueryRequestVariablesElem)} } - httpRequest, _, _, httpOptions, err := c.explainQuery(request, requestVars[0]) + httpRequest, _, metadata, httpOptions, err := c.explainQuery(request, requestVars[0]) if err != nil { return nil, err } - return serializeExplainResponse(httpRequest, httpOptions) + return c.serializeExplainResponse(ctx, httpRequest, metadata, httpOptions) } func (c *HTTPConnector) explainQuery(request *schema.QueryRequest, variables map[string]any) (*internal.RetryableRequest, *rest.OperationInfo, *configuration.NDCHttpRuntimeSchema, *internal.HTTPOptions, error) { @@ -67,6 +67,7 @@ func (c *HTTPConnector) explainQuery(request *schema.QueryRequest, variables map if err != nil { return nil, nil, nil, nil, err } + req.Namespace = metadata.Name if err := c.evalForwardedHeaders(req, rawArgs); err != nil { return nil, nil, nil, nil, schema.UnprocessableContentError("invalid forwarded headers", map[string]any{ @@ -81,8 +82,6 @@ func (c *HTTPConnector) explainQuery(request *schema.QueryRequest, variables map }) } - httpOptions.Settings = metadata.Settings - return req, function, &metadata, httpOptions, err } @@ -154,7 +153,7 @@ func (c *HTTPConnector) execQuery(ctx context.Context, state *State, request *sc } httpOptions.Concurrency = c.config.Concurrency.HTTP - client := internal.NewHTTPClient(c.client, metadata.NDCHttpSchema, c.config.ForwardHeaders, state.Tracer) + client := internal.NewHTTPClient(c.upstreams, metadata, c.config.ForwardHeaders, state.Tracer) result, _, err := client.Send(ctx, httpRequest, queryFields, function.ResultType, httpOptions) if err != nil { span.SetStatus(codes.Error, "failed to execute the http request") @@ -166,7 +165,7 @@ func (c *HTTPConnector) execQuery(ctx context.Context, state *State, request *sc return result, nil } -func serializeExplainResponse(httpRequest *internal.RetryableRequest, httpOptions *internal.HTTPOptions) (*schema.ExplainResponse, error) { +func (c *HTTPConnector) serializeExplainResponse(ctx context.Context, httpRequest *internal.RetryableRequest, metadata *configuration.NDCHttpRuntimeSchema, httpOptions *internal.HTTPOptions) (*schema.ExplainResponse, error) { explainResp := &schema.ExplainResponse{ Details: schema.ExplainResponseDetails{}, } @@ -183,11 +182,10 @@ func serializeExplainResponse(httpRequest *internal.RetryableRequest, httpOption httpOptions.Distributed = false httpOptions.Explain = true - requests, err := internal.BuildDistributedRequestsWithOptions(httpRequest, httpOptions) + requests, err := c.upstreams.BuildDistributedRequestsWithOptions(httpRequest, httpOptions) if err != nil { return nil, err } - explainResp.Details["url"] = requests[0].URL.String() if httpRequest.Body != nil { bodyBytes, err := io.ReadAll(httpRequest.Body) @@ -199,7 +197,17 @@ func serializeExplainResponse(httpRequest *internal.RetryableRequest, httpOption httpRequest.Body = nil explainResp.Details["body"] = string(bodyBytes) } - rawHeaders, err := json.Marshal(requests[0].Headers) + + req, cancel, err := requests[0].CreateRequest(ctx) + if err != nil { + return nil, err + } + defer cancel() + + c.upstreams.InjectMockRequestSettings(req, metadata.Name, requests[0].RawRequest.Security) + + explainResp.Details["url"] = req.URL.String() + rawHeaders, err := json.Marshal(req.Header) if err != nil { return nil, schema.InternalServerError("failed to encode headers", map[string]any{ "cause": err.Error(), diff --git a/connector/schema.go b/connector/schema.go index 3a5553e..5f0aadb 100644 --- a/connector/schema.go +++ b/connector/schema.go @@ -19,7 +19,7 @@ func (c *HTTPConnector) GetSchema(ctx context.Context, configuration *configurat } // ApplyNDCHttpSchemas applies slice of raw NDC HTTP schemas to the connector -func (c *HTTPConnector) ApplyNDCHttpSchemas(config *configuration.Configuration, schemas []configuration.NDCHttpRuntimeSchema, logger *slog.Logger) error { +func (c *HTTPConnector) ApplyNDCHttpSchemas(ctx context.Context, config *configuration.Configuration, schemas []configuration.NDCHttpRuntimeSchema, logger *slog.Logger) error { ndcSchema, metadata, errs := configuration.MergeNDCHttpSchemas(config, schemas) if len(errs) > 0 { printSchemaValidationError(logger, errs) @@ -28,6 +28,12 @@ func (c *HTTPConnector) ApplyNDCHttpSchemas(config *configuration.Configuration, } } + for _, meta := range metadata { + if err := c.upstreams.Register(ctx, meta.Name, meta.Settings); err != nil { + return err + } + } + schemaBytes, err := json.Marshal(ndcSchema.ToSchemaResponse()) if err != nil { return err diff --git a/connector/testdata/auth/schema.yaml b/connector/testdata/auth/schema.yaml index 9e68d9d..1dc65b8 100644 --- a/connector/testdata/auth/schema.yaml +++ b/connector/testdata/auth/schema.yaml @@ -1,4 +1,4 @@ ---- +# yaml-language-server: $schema=../../../ndc-http-schema/jsonschema/ndc-http-schema.schema.json settings: servers: - url: @@ -12,6 +12,7 @@ settings: name: api_key bearer: type: http + header: Authorization value: env: PET_STORE_BEARER_TOKEN scheme: bearer @@ -26,25 +27,34 @@ settings: petstore_auth: type: oauth2 flows: - implicit: - authorizationUrl: https://petstore3.swagger.io/oauth/authorize + clientCredentials: + tokenUrl: + value: http://localhost:4444/oauth2/token + clientId: + env: OAUTH2_CLIENT_ID + clientSecret: + env: OAUTH2_CLIENT_SECRET scopes: read:pets: read your pets write:pets: modify pets in your account + cookie: + type: cookie + oidc: + type: openIdConnect + openIdConnectUrl: http://localhost:4444/oauth2/token security: - api_key: [] version: 1.0.18 -collections: [] functions: findPets: request: url: "/pet" method: get - parameters: [] security: [] + response: + contentType: application/json arguments: {} description: Finds Pets - name: findPets result_type: element_type: name: Pet @@ -56,6 +66,8 @@ functions: method: get security: - bearer: [] + response: + contentType: application/json arguments: status: description: Status values that need to be considered for filter @@ -68,12 +80,7 @@ functions: in: query schema: type: [string] - enum: - - available - - pending - - sold description: Finds Pets by status - name: findPetsByStatus result_type: element_type: name: Pet @@ -83,10 +90,59 @@ functions: request: url: "/pet/retry" method: get - parameters: [] security: [] + response: + contentType: application/json + arguments: {} + result_type: + element_type: + name: Pet + type: named + type: array + findPetsOAuth: + request: + url: "/pet/oauth" + method: get + security: + - petstore_auth: [] + response: + contentType: application/json arguments: {} - name: petRetry + result_type: + element_type: + name: Pet + type: named + type: array + findPetsOAuthPassword: + request: + url: "/pet/oauth" + method: get + security: + - oauth_password: [] + response: + contentType: application/json + arguments: {} + result_type: + element_type: + name: Pet + type: named + type: array + findPetsCookie: + request: + url: "/pet/cookie" + method: get + security: + - cookie: [] + response: + contentType: application/json + arguments: + headers: + type: + type: nullable + underlying_type: + name: JSON + type: named + http: {} result_type: element_type: name: Pet @@ -104,6 +160,33 @@ procedures: - api_key: [] requestBody: contentType: application/json + response: + contentType: application/json + arguments: + body: + description: Request body of /pet + type: + name: Pet + type: named + http: + in: body + description: Add a new pet to the store + result_type: + name: Pet + type: named + addPetOidc: + request: + url: "/pet/oidc" + method: post + headers: + Content-Type: + value: application/json + security: + - oidc: [] + requestBody: + contentType: application/json + response: + contentType: application/json arguments: body: description: Request body of /pet @@ -113,7 +196,6 @@ procedures: http: in: body description: Add a new pet to the store - name: addPet result_type: name: Pet type: named @@ -133,7 +215,6 @@ procedures: type: name: CreateModelRequest type: named - name: createModel result_type: element_type: name: ProgressResponse @@ -196,7 +277,7 @@ object_types: name: JSON type: named http: - type: + type: [] id: type: type: nullable diff --git a/connector/types.go b/connector/types.go index fc50f61..fd9bd29 100644 --- a/connector/types.go +++ b/connector/types.go @@ -4,12 +4,10 @@ import ( "errors" "net/http" - "github.com/hasura/ndc-http/connector/internal" "github.com/hasura/ndc-sdk-go/connector" ) var ( - errInvalidSchema = errors.New("failed to validate NDC HTTP schema") errBuildSchemaFailed = errors.New("failed to build NDC HTTP schema") ) @@ -19,7 +17,7 @@ type State struct { } type options struct { - client internal.Doer + client *http.Client } var defaultOptions options = options{ @@ -32,7 +30,7 @@ var defaultOptions options = options{ type Option (func(*options)) // WithClient sets the custom HTTP client that satisfy the Doer interface -func WithClient(client internal.Doer) Option { +func WithClient(client *http.Client) Option { return func(opts *options) { opts.client = client } diff --git a/go.mod b/go.mod index 39428e1..b9ae1e4 100644 --- a/go.mod +++ b/go.mod @@ -7,10 +7,11 @@ toolchain go1.23.1 require ( github.com/go-viper/mapstructure/v2 v2.2.1 github.com/google/uuid v1.6.0 - github.com/hasura/ndc-http/ndc-http-schema v0.0.0-00010101000000-000000000000 - github.com/hasura/ndc-sdk-go v1.6.2-0.20241109102535-399b739f7af5 + github.com/hasura/ndc-http/ndc-http-schema v0.0.0-20241124160706-95bf5710211d + github.com/hasura/ndc-sdk-go v1.6.3-0.20241127025002-02d7a257e75f go.opentelemetry.io/otel v1.32.0 go.opentelemetry.io/otel/trace v1.32.0 + golang.org/x/oauth2 v0.24.0 golang.org/x/sync v0.9.0 gotest.tools/v3 v3.5.1 ) @@ -27,12 +28,12 @@ require ( github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/google/go-cmp v0.6.0 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 // indirect github.com/invopop/jsonschema v0.12.0 // indirect github.com/klauspost/compress v1.17.11 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/pb33f/libopenapi v0.18.6 // indirect + github.com/pb33f/libopenapi v0.18.7 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/prometheus/client_golang v1.20.5 // indirect github.com/prometheus/client_model v0.6.1 // indirect @@ -54,10 +55,10 @@ require ( golang.org/x/net v0.31.0 // indirect golang.org/x/sys v0.27.0 // indirect golang.org/x/text v0.20.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20241104194629-dd2ea8efbc28 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20241104194629-dd2ea8efbc28 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20241118233622-e639e219e697 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20241118233622-e639e219e697 // indirect google.golang.org/grpc v1.68.0 // indirect - google.golang.org/protobuf v1.35.1 // indirect + google.golang.org/protobuf v1.35.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 2122b92..ef2d2db 100644 --- a/go.sum +++ b/go.sum @@ -56,10 +56,10 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0 h1:ad0vkEBuk23VJzZR9nkLVG0YAoN9coASF1GusYX6AlU= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0/go.mod h1:igFoXX2ELCW06bol23DWPB5BEWfZISOzSP5K2sbLea0= -github.com/hasura/ndc-sdk-go v1.6.2-0.20241109102535-399b739f7af5 h1:YA3ix2/SMZ+vR/96YXuSPNYHsocsWnY8xCmhJeT3RYs= -github.com/hasura/ndc-sdk-go v1.6.2-0.20241109102535-399b739f7af5/go.mod h1:H7iN3SFXSou2rjBKv9fLumbvDXMDGP0Eg+cXWHpkA3k= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 h1:TmHmbvxPmaegwhDubVz0lICL0J5Ka2vwTzhoePEXsGE= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0/go.mod h1:qztMSjm835F2bXf+5HKAPIS5qsmQDqZna/PgVt4rWtI= +github.com/hasura/ndc-sdk-go v1.6.3-0.20241127025002-02d7a257e75f h1:Tnm6+0GQxHwZkZkXOQOcF1pmrNWfKEsUXguapvxSBRE= +github.com/hasura/ndc-sdk-go v1.6.3-0.20241127025002-02d7a257e75f/go.mod h1:H7iN3SFXSou2rjBKv9fLumbvDXMDGP0Eg+cXWHpkA3k= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= @@ -98,8 +98,8 @@ github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1y github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw= github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= -github.com/pb33f/libopenapi v0.18.6 h1:adxzZUnOBOAuKxFAIrtb1Qt8GA4XnDWUAxEnqiSoTh0= -github.com/pb33f/libopenapi v0.18.6/go.mod h1:qZRs2IHIcs9SjHPmQfSUCyeD3OY9JkLJQOuFxd0bYCY= +github.com/pb33f/libopenapi v0.18.7 h1:gLD4gQ88zEqv7x13SDzk3AUdpHUp9gWrP1NDwrFTy+U= +github.com/pb33f/libopenapi v0.18.7/go.mod h1:qZRs2IHIcs9SjHPmQfSUCyeD3OY9JkLJQOuFxd0bYCY= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -168,6 +168,8 @@ golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo= golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM= +golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE= +golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -204,10 +206,10 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/genproto/googleapis/api v0.0.0-20241104194629-dd2ea8efbc28 h1:M0KvPgPmDZHPlbRbaNU1APr28TvwvvdUPlSv7PUvy8g= -google.golang.org/genproto/googleapis/api v0.0.0-20241104194629-dd2ea8efbc28/go.mod h1:dguCy7UOdZhTvLzDyt15+rOrawrpM4q7DD9dQ1P11P4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20241104194629-dd2ea8efbc28 h1:XVhgTWWV3kGQlwJHR3upFWZeTsei6Oks1apkZSeonIE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20241104194629-dd2ea8efbc28/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI= +google.golang.org/genproto/googleapis/api v0.0.0-20241118233622-e639e219e697 h1:pgr/4QbFyktUv9CtQ/Fq4gzEE6/Xs7iCXbktaGzLHbQ= +google.golang.org/genproto/googleapis/api v0.0.0-20241118233622-e639e219e697/go.mod h1:+D9ySVjN8nY8YCVjc5O7PZDIdZporIDY3KaGfJunh88= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241118233622-e639e219e697 h1:LWZqQOEjDyONlF1H6afSWpAL/znlREo2tHfLoe+8LMA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241118233622-e639e219e697/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU= google.golang.org/grpc v1.68.0 h1:aHQeeJbo8zAkAa3pRzrVjZlbz6uSfeOXlJNQM0RAbz0= google.golang.org/grpc v1.68.0/go.mod h1:fmSPC5AsjSBCK54MyHRx48kpOti1/jRfOlwEWywNjWA= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= @@ -218,8 +220,8 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= -google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= +google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/ndc-http-schema/configuration/schema.go b/ndc-http-schema/configuration/schema.go index 32933f7..3afdd2f 100644 --- a/ndc-http-schema/configuration/schema.go +++ b/ndc-http-schema/configuration/schema.go @@ -221,6 +221,14 @@ func buildSchemaFile(config *Configuration, configDir string, configItem *Config } func buildHTTPArguments(config *Configuration, restSchema *rest.NDCHttpSchema, conf *ConfigItem) { + for _, fn := range restSchema.Functions { + applyForwardingHeadersArgument(config, &fn) + } + + for _, proc := range restSchema.Procedures { + applyForwardingHeadersArgument(config, &proc) + } + if restSchema.Settings == nil || len(restSchema.Settings.Servers) < 2 { return } @@ -243,11 +251,11 @@ func buildHTTPArguments(config *Configuration, restSchema *rest.NDCHttpSchema, c restSchema.ObjectTypes[rest.HTTPSingleOptionsObjectName] = singleObjectType for _, fn := range restSchema.Functions { - applyOperationInfo(config, &fn) + fn.Arguments[rest.HTTPOptionsArgumentName] = httpSingleOptionsArgument } for _, proc := range restSchema.Procedures { - applyOperationInfo(config, &proc) + proc.Arguments[rest.HTTPOptionsArgumentName] = httpSingleOptionsArgument } if !conf.IsDistributed() { @@ -334,8 +342,7 @@ func buildHeadersForwardingResponse(config *Configuration, restSchema *rest.NDCH } } -func applyOperationInfo(config *Configuration, info *rest.OperationInfo) { - info.Arguments[rest.HTTPOptionsArgumentName] = httpSingleOptionsArgument +func applyForwardingHeadersArgument(config *Configuration, info *rest.OperationInfo) { if config.ForwardHeaders.Enabled && config.ForwardHeaders.ArgumentField != nil { info.Arguments[*config.ForwardHeaders.ArgumentField] = headersArguments } diff --git a/ndc-http-schema/go.mod b/ndc-http-schema/go.mod index 6af562f..7b4052b 100644 --- a/ndc-http-schema/go.mod +++ b/ndc-http-schema/go.mod @@ -8,10 +8,10 @@ require ( github.com/alecthomas/kong v1.4.0 github.com/evanphx/json-patch v0.5.2 github.com/google/go-cmp v0.6.0 - github.com/hasura/ndc-sdk-go v1.6.2-0.20241109102535-399b739f7af5 + github.com/hasura/ndc-sdk-go v1.6.3-0.20241127025002-02d7a257e75f github.com/invopop/jsonschema v0.12.0 github.com/lmittmann/tint v1.0.5 - github.com/pb33f/libopenapi v0.18.6 + github.com/pb33f/libopenapi v0.18.7 github.com/wk8/go-ordered-map/v2 v2.1.9-0.20240815153524-6ea36470d1bd gopkg.in/yaml.v3 v3.0.1 gotest.tools/v3 v3.5.1 @@ -32,5 +32,5 @@ require ( go.opentelemetry.io/otel v1.32.0 // indirect golang.org/x/net v0.30.0 // indirect golang.org/x/sys v0.26.0 // indirect - google.golang.org/protobuf v1.35.1 // indirect + google.golang.org/protobuf v1.35.2 // indirect ) diff --git a/ndc-http-schema/go.sum b/ndc-http-schema/go.sum index 51ca3fd..38b530d 100644 --- a/ndc-http-schema/go.sum +++ b/ndc-http-schema/go.sum @@ -43,8 +43,8 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/hasura/ndc-sdk-go v1.6.2-0.20241109102535-399b739f7af5 h1:YA3ix2/SMZ+vR/96YXuSPNYHsocsWnY8xCmhJeT3RYs= -github.com/hasura/ndc-sdk-go v1.6.2-0.20241109102535-399b739f7af5/go.mod h1:H7iN3SFXSou2rjBKv9fLumbvDXMDGP0Eg+cXWHpkA3k= +github.com/hasura/ndc-sdk-go v1.6.3-0.20241127025002-02d7a257e75f h1:Tnm6+0GQxHwZkZkXOQOcF1pmrNWfKEsUXguapvxSBRE= +github.com/hasura/ndc-sdk-go v1.6.3-0.20241127025002-02d7a257e75f/go.mod h1:H7iN3SFXSou2rjBKv9fLumbvDXMDGP0Eg+cXWHpkA3k= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= @@ -77,8 +77,8 @@ github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1y github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw= github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= -github.com/pb33f/libopenapi v0.18.6 h1:adxzZUnOBOAuKxFAIrtb1Qt8GA4XnDWUAxEnqiSoTh0= -github.com/pb33f/libopenapi v0.18.6/go.mod h1:qZRs2IHIcs9SjHPmQfSUCyeD3OY9JkLJQOuFxd0bYCY= +github.com/pb33f/libopenapi v0.18.7 h1:gLD4gQ88zEqv7x13SDzk3AUdpHUp9gWrP1NDwrFTy+U= +github.com/pb33f/libopenapi v0.18.7/go.mod h1:qZRs2IHIcs9SjHPmQfSUCyeD3OY9JkLJQOuFxd0bYCY= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -157,8 +157,8 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= -google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= +google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/ndc-http-schema/jsonschema/configuration.schema.json b/ndc-http-schema/jsonschema/configuration.schema.json index 3a22ec4..4bcae2e 100644 --- a/ndc-http-schema/jsonschema/configuration.schema.json +++ b/ndc-http-schema/jsonschema/configuration.schema.json @@ -64,6 +64,10 @@ "type": "boolean", "description": "Require strict validation" }, + "noDeprecation": { + "type": "boolean", + "description": "Ignore deprecated fields." + }, "patchBefore": { "items": { "$ref": "#/$defs/PatchConfig" diff --git a/ndc-http-schema/jsonschema/convert-config.schema.json b/ndc-http-schema/jsonschema/convert-config.schema.json index 38960d4..9086cac 100644 --- a/ndc-http-schema/jsonschema/convert-config.schema.json +++ b/ndc-http-schema/jsonschema/convert-config.schema.json @@ -40,6 +40,10 @@ "type": "boolean", "description": "Require strict validation" }, + "noDeprecation": { + "type": "boolean", + "description": "Ignore deprecated fields." + }, "patchBefore": { "items": { "$ref": "#/$defs/PatchConfig" diff --git a/ndc-http-schema/jsonschema/generator.go b/ndc-http-schema/jsonschema/generator.go index bc1fce5..5c12ce8 100644 --- a/ndc-http-schema/jsonschema/generator.go +++ b/ndc-http-schema/jsonschema/generator.go @@ -58,7 +58,11 @@ func jsonSchemaNDCHttpSchema() error { return err } + flowSchema := r.Reflect(&schema.OAuthFlow{}) reflectSchema := r.Reflect(&schema.NDCHttpSchema{}) + for k, def := range flowSchema.Definitions { + reflectSchema.Definitions[k] = def + } schemaBytes, err := json.MarshalIndent(reflectSchema, "", " ") if err != nil { return err diff --git a/ndc-http-schema/jsonschema/ndc-http-schema.schema.json b/ndc-http-schema/jsonschema/ndc-http-schema.schema.json index 598b32f..9ad91e8 100644 --- a/ndc-http-schema/jsonschema/ndc-http-schema.schema.json +++ b/ndc-http-schema/jsonschema/ndc-http-schema.schema.json @@ -244,6 +244,40 @@ ], "description": "NDCHttpSettings represent global settings of the HTTP API, including base URL, headers, etc..." }, + "OAuthFlow": { + "properties": { + "authorizationUrl": { + "type": "string" + }, + "tokenUrl": { + "$ref": "#/$defs/EnvString" + }, + "refreshUrl": { + "$ref": "#/$defs/EnvString" + }, + "scopes": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "clientId": { + "$ref": "#/$defs/EnvString" + }, + "clientSecret": { + "$ref": "#/$defs/EnvString" + }, + "endpointParams": { + "additionalProperties": { + "$ref": "#/$defs/EnvString" + }, + "type": "object" + } + }, + "additionalProperties": false, + "type": "object", + "description": "OAuthFlow contains flow configurations for OAuth 2.0 API specification\n\n[OAuth 2.0]: https://swagger.io/docs/specification/authentication/oauth2" + }, "ObjectField": { "properties": { "arguments": { @@ -285,6 +319,10 @@ }, "type": "object", "description": "Fields defined on this object type" + }, + "xml": { + "$ref": "#/$defs/XMLSchema", + "description": "XML schema" } }, "additionalProperties": false, @@ -624,8 +662,52 @@ ] }, "flows": { - "additionalProperties": true, - "type": "object" + "oneOf": [ + { + "properties": { + "password": { + "$ref": "#/$defs/OAuthFlow" + } + }, + "type": "object", + "required": [ + "password" + ] + }, + { + "properties": { + "implicit": { + "$ref": "#/$defs/OAuthFlow" + } + }, + "type": "object", + "required": [ + "implicit" + ] + }, + { + "properties": { + "clientCredentials": { + "$ref": "#/$defs/OAuthFlow" + } + }, + "type": "object", + "required": [ + "clientCredentials" + ] + }, + { + "properties": { + "authorizationCode": { + "$ref": "#/$defs/OAuthFlow" + } + }, + "type": "object", + "required": [ + "authorizationCode" + ] + } + ] } }, "type": "object", @@ -808,6 +890,9 @@ }, "items": { "$ref": "#/$defs/TypeSchema" + }, + "xml": { + "$ref": "#/$defs/XMLSchema" } }, "additionalProperties": false, @@ -816,6 +901,37 @@ "type" ], "description": "TypeSchema represents a serializable object of OpenAPI schema that is used for validation" + }, + "XMLSchema": { + "properties": { + "name": { + "type": "string", + "description": "Replaces the name of the element/attribute used for the described schema property.\nWhen defined within items, it will affect the name of the individual XML elements within the list.\nWhen defined alongside type being array (outside the items), it will affect the wrapping element and only if wrapped is true.\nIf wrapped is false, it will be ignored." + }, + "prefix": { + "type": "string", + "description": "The prefix to be used for the name." + }, + "namespace": { + "type": "string", + "description": "The URI of the namespace definition. This MUST be in the form of an absolute URI." + }, + "wrapped": { + "type": "boolean", + "description": "Used only for an array definition. Signifies whether the array is wrapped (for example, \u003cbooks\u003e\u003cbook/\u003e\u003cbook/\u003e\u003c/books\u003e) or unwrapped (\u003cbook/\u003e\u003cbook/\u003e)." + }, + "attribute": { + "type": "boolean", + "description": "Declares whether the property definition translates to an attribute instead of an element." + }, + "text": { + "type": "boolean", + "description": "Represents a text value of the xml element." + } + }, + "additionalProperties": false, + "type": "object", + "description": "XMLSchema represents a XML schema that adds additional metadata to describe the XML representation of this property." } } } \ No newline at end of file diff --git a/ndc-http-schema/openapi/internal/oas2.go b/ndc-http-schema/openapi/internal/oas2.go index 287f6b6..03b9156 100644 --- a/ndc-http-schema/openapi/internal/oas2.go +++ b/ndc-http-schema/openapi/internal/oas2.go @@ -53,7 +53,7 @@ func (oc *OAS2Builder) BuildDocumentModel(docModel *libopenapi.DocumentModel[v2. } } envName := utils.StringSliceToConstantCase([]string{oc.EnvPrefix, "SERVER_URL"}) - serverURL := fmt.Sprintf("%s://%s%s", scheme, docModel.Model.Host, docModel.Model.BasePath) + serverURL := strings.TrimRight(fmt.Sprintf("%s://%s%s", scheme, docModel.Model.Host, docModel.Model.BasePath), "/") oc.schema.Settings.Servers = append(oc.schema.Settings.Servers, rest.ServerConfig{ URL: sdkUtils.NewEnvString(envName, serverURL), }) @@ -122,9 +122,14 @@ func (oc *OAS2Builder) convertSecuritySchemes(scheme orderedmap.Pair[string, *v2 } flow := rest.OAuthFlow{ AuthorizationURL: security.AuthorizationUrl, - TokenURL: security.TokenUrl, } + tokenURL := sdkUtils.NewEnvStringVariable(utils.StringSliceToConstantCase([]string{oc.EnvPrefix, key, "TOKEN_URL"})) + if security.TokenUrl != "" { + tokenURL.Value = &security.TokenUrl + } + flow.TokenURL = &tokenURL + if security.Scopes != nil { scopes := make(map[string]string) for scope := security.Scopes.Values.First(); scope != nil; scope = scope.Next() { @@ -133,6 +138,13 @@ func (oc *OAS2Builder) convertSecuritySchemes(scheme orderedmap.Pair[string, *v2 flow.Scopes = scopes } + if flowType == rest.ClientCredentialsFlow { + clientID := sdkUtils.NewEnvStringVariable(utils.StringSliceToConstantCase([]string{oc.EnvPrefix, key, "CLIENT_ID"})) + clientSecret := sdkUtils.NewEnvStringVariable(utils.StringSliceToConstantCase([]string{oc.EnvPrefix, key, "CLIENT_SECRET"})) + flow.ClientID = &clientID + flow.ClientSecret = &clientSecret + } + result.SecuritySchemer = rest.NewOAuth2Config(map[rest.OAuthFlowType]rest.OAuthFlow{ flowType: flow, }) diff --git a/ndc-http-schema/openapi/internal/oas3.go b/ndc-http-schema/openapi/internal/oas3.go index 7050793..2951161 100644 --- a/ndc-http-schema/openapi/internal/oas3.go +++ b/ndc-http-schema/openapi/internal/oas3.go @@ -114,7 +114,7 @@ func (oc *OAS3Builder) convertServers(servers []*v3.Server) []rest.ServerConfig conf := rest.ServerConfig{ ID: serverID, - URL: sdkUtils.NewEnvString(envName, serverURL), + URL: sdkUtils.NewEnvString(envName, strings.TrimRight(serverURL, "/")), } results = append(results, conf) } @@ -164,16 +164,23 @@ func (oc *OAS3Builder) convertSecuritySchemes(scheme orderedmap.Pair[string, *v3 flows := make(map[rest.OAuthFlowType]rest.OAuthFlow) if security.Flows.Implicit != nil { - flows[rest.ImplicitFlow] = *convertV3OAuthFLow(security.Flows.Implicit) + flows[rest.ImplicitFlow] = oc.convertV3OAuthFLow(key, security.Flows.Implicit) } if security.Flows.AuthorizationCode != nil { - flows[rest.AuthorizationCodeFlow] = *convertV3OAuthFLow(security.Flows.AuthorizationCode) + flows[rest.AuthorizationCodeFlow] = oc.convertV3OAuthFLow(key, security.Flows.AuthorizationCode) } if security.Flows.ClientCredentials != nil { - flows[rest.ClientCredentialsFlow] = *convertV3OAuthFLow(security.Flows.ClientCredentials) + clientID := sdkUtils.NewEnvStringVariable(utils.StringSliceToConstantCase([]string{oc.EnvPrefix, key, "CLIENT_ID"})) + clientSecret := sdkUtils.NewEnvStringVariable(utils.StringSliceToConstantCase([]string{oc.EnvPrefix, key, "CLIENT_SECRET"})) + flow := oc.convertV3OAuthFLow(key, security.Flows.ClientCredentials) + flow.ClientID = &clientID + flow.ClientSecret = &clientSecret + + flows[rest.ClientCredentialsFlow] = flow } + if security.Flows.Password != nil { - flows[rest.PasswordFlow] = *convertV3OAuthFLow(security.Flows.Password) + flows[rest.PasswordFlow] = oc.convertV3OAuthFLow(key, security.Flows.Password) } result.SecuritySchemer = rest.NewOAuth2Config(flows) @@ -399,11 +406,20 @@ func (oc *OAS3Builder) populateWriteSchemaType(schemaType schema.Type) (schema.T } } -func convertV3OAuthFLow(input *v3.OAuthFlow) *rest.OAuthFlow { - result := &rest.OAuthFlow{ +func (oc *OAS3Builder) convertV3OAuthFLow(key string, input *v3.OAuthFlow) rest.OAuthFlow { + result := rest.OAuthFlow{ AuthorizationURL: input.AuthorizationUrl, - TokenURL: input.TokenUrl, - RefreshURL: input.RefreshUrl, + } + + tokenURL := sdkUtils.NewEnvStringVariable(utils.StringSliceToConstantCase([]string{oc.EnvPrefix, key, "TOKEN_URL"})) + if input.TokenUrl != "" { + tokenURL.Value = &input.TokenUrl + } + result.TokenURL = &tokenURL + + if input.RefreshUrl != "" { + refreshURL := sdkUtils.NewEnvString(utils.StringSliceToConstantCase([]string{oc.EnvPrefix, key, "REFRESH_URL"}), input.TokenUrl) + result.RefreshURL = &refreshURL } if input.Scopes != nil { diff --git a/ndc-http-schema/openapi/testdata/petstore2/expected.json b/ndc-http-schema/openapi/testdata/petstore2/expected.json index 0884050..6d8291e 100644 --- a/ndc-http-schema/openapi/testdata/petstore2/expected.json +++ b/ndc-http-schema/openapi/testdata/petstore2/expected.json @@ -33,6 +33,9 @@ "flows": { "implicit": { "authorizationUrl": "https://petstore.swagger.io/oauth/authorize", + "tokenUrl": { + "env": "PETSTORE_AUTH_TOKEN_URL" + }, "scopes": { "read:pets": "read your pets", "write:pets": "modify pets in your account" @@ -1961,7 +1964,6 @@ } }, "xml": { - "name": "", "wrapped": true } } @@ -1997,7 +1999,6 @@ "array" ], "xml": { - "name": "", "wrapped": true } } diff --git a/ndc-http-schema/openapi/testdata/petstore3/expected.json b/ndc-http-schema/openapi/testdata/petstore3/expected.json index 2e69209..9030d32 100644 --- a/ndc-http-schema/openapi/testdata/petstore3/expected.json +++ b/ndc-http-schema/openapi/testdata/petstore3/expected.json @@ -39,6 +39,9 @@ "flows": { "implicit": { "authorizationUrl": "https://petstore3.swagger.io/oauth/authorize", + "tokenUrl": { + "env": "PET_STORE_PETSTORE_AUTH_TOKEN_URL" + }, "scopes": { "read:pets": "read your pets", "write:pets": "modify pets in your account" diff --git a/ndc-http-schema/schema/auth.go b/ndc-http-schema/schema/auth.go index 606e8ce..067910e 100644 --- a/ndc-http-schema/schema/auth.go +++ b/ndc-http-schema/schema/auth.go @@ -182,16 +182,6 @@ func (j SecurityScheme) JSONSchema() *jsonschema.Schema { }, }) - oauth2Schema := orderedmap.New[string, *jsonschema.Schema]() - oauth2Schema.Set("type", &jsonschema.Schema{ - Type: "string", - Enum: []any{OAuth2Scheme}, - }) - oauth2Schema.Set("flows", &jsonschema.Schema{ - Type: "object", - AdditionalProperties: &jsonschema.Schema{}, - }) - oidcSchema := orderedmap.New[string, *jsonschema.Schema]() oidcSchema.Set("type", &jsonschema.Schema{ Type: "string", @@ -230,11 +220,7 @@ func (j SecurityScheme) JSONSchema() *jsonschema.Schema { Properties: httpAuthSchema, Required: []string{"type", "value", "header", "scheme"}, }, - { - Type: "object", - Properties: oauth2Schema, - Required: []string{"type", "flows"}, - }, + OAuth2Config{}.JSONSchema(), { Type: "object", Properties: oidcSchema, @@ -336,9 +322,6 @@ type APIKeyAuthConfig struct { In APIKeyLocation `json:"in" mapstructure:"in" yaml:"in"` Name string `json:"name" mapstructure:"name" yaml:"name"` Value utils.EnvString `json:"value" mapstructure:"value" yaml:"value"` - - // cached values - value *string } var _ SecuritySchemer = &APIKeyAuthConfig{} @@ -378,14 +361,6 @@ func (ss *APIKeyAuthConfig) Validate() error { return err } - value, err := ss.Value.Get() - if err != nil { - return fmt.Errorf("APIKeyAuthConfig.Value: %w", err) - } - if value != "" { - ss.value = &value - } - return nil } @@ -394,17 +369,6 @@ func (ss APIKeyAuthConfig) GetType() SecuritySchemeType { return ss.Type } -// GetValue get the authentication credential value -func (ss APIKeyAuthConfig) GetValue() string { - if ss.value != nil { - return *ss.value - } - - value, _ := ss.Value.Get() - - return value -} - // HTTPAuthConfig contains configurations for http authentication // If the scheme is [bearer], the authenticator follows OpenAPI 3 specification. // @@ -414,9 +378,6 @@ type HTTPAuthConfig struct { Header string `json:"header" mapstructure:"header" yaml:"header"` Scheme string `json:"scheme" mapstructure:"scheme" yaml:"scheme"` Value utils.EnvString `json:"value" mapstructure:"value" yaml:"value"` - - // cached values - value *string } var _ SecuritySchemer = &HTTPAuthConfig{} @@ -437,14 +398,6 @@ func (ss *HTTPAuthConfig) Validate() error { return errors.New("schema is required for http security") } - value, err := ss.Value.Get() - if err != nil { - return fmt.Errorf("APIKeyAuthConfig.Value: %w", err) - } - if value != "" { - ss.value = &value - } - return nil } @@ -453,17 +406,6 @@ func (ss HTTPAuthConfig) GetType() SecuritySchemeType { return ss.Type } -// GetValue get the authentication credential value -func (ss HTTPAuthConfig) GetValue() string { - if ss.value != nil { - return *ss.value - } - - value, _ := ss.Value.Get() - - return value -} - // BasicAuthConfig contains configurations for the [basic] authentication. // // [basic]: https://swagger.io/docs/specification/authentication/basic-authentication @@ -472,10 +414,6 @@ type BasicAuthConfig struct { Header string `json:"header" mapstructure:"header" yaml:"header"` Username utils.EnvString `json:"username" mapstructure:"username" yaml:"username"` Password utils.EnvString `json:"password" mapstructure:"password" yaml:"password"` - - // cached values - username *string - password *string } // NewBasicAuthConfig creates a new BasicAuthConfig instance. @@ -489,20 +427,6 @@ func NewBasicAuthConfig(username, password utils.EnvString) *BasicAuthConfig { // Validate if the current instance is valid func (ss *BasicAuthConfig) Validate() error { - user, err := ss.Username.Get() - if err != nil { - return fmt.Errorf("BasicAuthConfig.User: %w", err) - } - - // user and password can be empty. - ss.username = &user - - password, err := ss.Password.Get() - if err != nil { - return fmt.Errorf("BasicAuthConfig.Password: %w", err) - } - ss.password = &password - return nil } @@ -511,28 +435,6 @@ func (ss BasicAuthConfig) GetType() SecuritySchemeType { return ss.Type } -// GetUsername get the username value -func (ss BasicAuthConfig) GetUsername() string { - if ss.username != nil { - return *ss.username - } - - value, _ := ss.Username.Get() - - return value -} - -// GetPassword get the password value -func (ss BasicAuthConfig) GetPassword() string { - if ss.password != nil { - return *ss.password - } - - value, _ := ss.Password.Get() - - return value -} - // OAuthFlowType represents the OAuth flow type enum type OAuthFlowType string @@ -581,33 +483,39 @@ func ParseOAuthFlowType(value string) (OAuthFlowType, error) { // // [OAuth 2.0]: https://swagger.io/docs/specification/authentication/oauth2 type OAuthFlow struct { - AuthorizationURL string `json:"authorizationUrl,omitempty" mapstructure:"authorizationUrl" yaml:"authorizationUrl,omitempty"` - TokenURL string `json:"tokenUrl,omitempty" mapstructure:"tokenUrl" yaml:"tokenUrl,omitempty"` - RefreshURL string `json:"refreshUrl,omitempty" mapstructure:"refreshUrl" yaml:"refreshUrl,omitempty"` - Scopes map[string]string `json:"scopes,omitempty" mapstructure:"scopes" yaml:"scopes,omitempty"` + AuthorizationURL string `json:"authorizationUrl,omitempty" mapstructure:"authorizationUrl" yaml:"authorizationUrl,omitempty"` + TokenURL *utils.EnvString `json:"tokenUrl,omitempty" mapstructure:"tokenUrl" yaml:"tokenUrl,omitempty"` + RefreshURL *utils.EnvString `json:"refreshUrl,omitempty" mapstructure:"refreshUrl" yaml:"refreshUrl,omitempty"` + Scopes map[string]string `json:"scopes,omitempty" mapstructure:"scopes" yaml:"scopes,omitempty"` + ClientID *utils.EnvString `json:"clientId,omitempty" mapstructure:"clientId" yaml:"clientId,omitempty"` + ClientSecret *utils.EnvString `json:"clientSecret,omitempty" mapstructure:"clientSecret" yaml:"clientSecret,omitempty"` + EndpointParams map[string]utils.EnvString `json:"endpointParams,omitempty" mapstructure:"endpointParams" yaml:"endpointParams,omitempty"` } // Validate if the current instance is valid func (ss OAuthFlow) Validate(flowType OAuthFlowType) error { - if ss.AuthorizationURL == "" { - if slices.Contains([]OAuthFlowType{ImplicitFlow, AuthorizationCodeFlow}, flowType) { - return fmt.Errorf("authorizationUrl is required for oauth2 %s security", flowType) - } - } else if _, err := parseRelativeOrHttpURL(ss.AuthorizationURL); err != nil { - return fmt.Errorf("authorizationUrl: %w", err) - } - - if ss.TokenURL == "" { + if ss.TokenURL == nil { if slices.Contains([]OAuthFlowType{PasswordFlow, ClientCredentialsFlow, AuthorizationCodeFlow}, flowType) { return fmt.Errorf("tokenUrl is required for oauth2 %s security", flowType) } - } else if _, err := parseRelativeOrHttpURL(ss.TokenURL); err != nil { - return fmt.Errorf("tokenUrl: %w", err) + } else if ss.TokenURL.Value == nil && ss.TokenURL.Variable == nil { + return errors.New("tokenUrl: value and env are empty") } - if ss.RefreshURL != "" { - if _, err := parseRelativeOrHttpURL(ss.RefreshURL); err != nil { - return fmt.Errorf("refreshUrl: %w", err) - } + + if flowType != ClientCredentialsFlow { + return nil + } + + if ss.ClientID == nil { + return errors.New("clientId is required for the OAuth2 client_credentials flow") + } else if ss.ClientID.Value == nil && ss.ClientID.Variable == nil { + return errors.New("clientId: value and env are empty") + } + + if ss.ClientSecret == nil { + return errors.New("clientSecret is required for the OAuth2 client_credentials flow") + } else if ss.ClientSecret.Value == nil && ss.ClientSecret.Variable == nil { + return errors.New("clientSecret: value and env are empty") } return nil @@ -651,6 +559,61 @@ func (ss OAuth2Config) Validate() error { return nil } +// JSONSchema is used to generate a custom jsonschema +func (j OAuth2Config) JSONSchema() *jsonschema.Schema { + oauth2Schema := orderedmap.New[string, *jsonschema.Schema]() + oauth2Schema.Set("type", &jsonschema.Schema{ + Type: "string", + Enum: []any{OAuth2Scheme}, + }) + + oauthFlowRef := &jsonschema.Schema{ + Ref: "#/$defs/OAuthFlow", + } + implicitFlow := orderedmap.New[string, *jsonschema.Schema]() + implicitFlow.Set(string(ImplicitFlow), oauthFlowRef) + + passwordFlow := orderedmap.New[string, *jsonschema.Schema]() + passwordFlow.Set(string(PasswordFlow), oauthFlowRef) + + ccFlow := orderedmap.New[string, *jsonschema.Schema]() + ccFlow.Set(string(ClientCredentialsFlow), oauthFlowRef) + + acFlow := orderedmap.New[string, *jsonschema.Schema]() + acFlow.Set(string(AuthorizationCodeFlow), oauthFlowRef) + + oauth2Schema.Set("flows", &jsonschema.Schema{ + OneOf: []*jsonschema.Schema{ + { + Type: "object", + Required: []string{string(PasswordFlow)}, + Properties: passwordFlow, + }, + { + Type: "object", + Required: []string{string(ImplicitFlow)}, + Properties: implicitFlow, + }, + { + Type: "object", + Required: []string{string(ClientCredentialsFlow)}, + Properties: ccFlow, + }, + { + Type: "object", + Required: []string{string(AuthorizationCodeFlow)}, + Properties: acFlow, + }, + }, + }) + + return &jsonschema.Schema{ + Type: "object", + Properties: oauth2Schema, + Required: []string{"type", "flows"}, + } +} + // OpenIDConnectConfig contains configurations for [OpenID Connect] API specification // // [OpenID Connect]: https://swagger.io/docs/specification/authentication/openid-connect-discovery @@ -680,7 +643,7 @@ func (ss OpenIDConnectConfig) Validate() error { return errors.New("openIdConnectUrl is required for oidc security") } - if _, err := parseRelativeOrHttpURL(ss.OpenIDConnectURL); err != nil { + if _, err := ParseRelativeOrHttpURL(ss.OpenIDConnectURL); err != nil { return fmt.Errorf("openIdConnectUrl: %w", err) } diff --git a/ndc-http-schema/schema/setting.go b/ndc-http-schema/schema/setting.go index 561c58c..6a4781f 100644 --- a/ndc-http-schema/schema/setting.go +++ b/ndc-http-schema/schema/setting.go @@ -17,8 +17,6 @@ type NDCHttpSettings struct { SecuritySchemes map[string]SecurityScheme `json:"securitySchemes,omitempty" mapstructure:"securitySchemes" yaml:"securitySchemes,omitempty"` Security AuthSecurities `json:"security,omitempty" mapstructure:"security" yaml:"security,omitempty"` Version string `json:"version,omitempty" mapstructure:"version" yaml:"version,omitempty"` - - headers map[string]string } // UnmarshalJSON implements json.Unmarshaler. @@ -52,24 +50,9 @@ func (rs *NDCHttpSettings) Validate() error { } } - headers, err := getHeadersFromEnv(rs.Headers) - if err != nil { - return err - } - rs.headers = headers - return nil } -// Validate if the current instance is valid -func (rs NDCHttpSettings) GetHeaders() map[string]string { - if rs.headers != nil { - return rs.headers - } - - return getHeadersFromEnvUnsafe(rs.Headers) -} - // ServerConfig contains server configurations type ServerConfig struct { URL utils.EnvString `json:"url" mapstructure:"url" yaml:"url"` @@ -78,10 +61,6 @@ type ServerConfig struct { SecuritySchemes map[string]SecurityScheme `json:"securitySchemes,omitempty" mapstructure:"securitySchemes" yaml:"securitySchemes,omitempty"` Security AuthSecurities `json:"security,omitempty" mapstructure:"security" yaml:"security,omitempty"` TLS *TLSConfig `json:"tls,omitempty" mapstructure:"tls" yaml:"tls,omitempty"` - - // cached values that are loaded from environment variables - url *url.URL - headers map[string]string } // UnmarshalJSON implements json.Unmarshaler. @@ -112,47 +91,26 @@ func (ss *ServerConfig) Validate() error { return errors.New("url is required for server") } - urlValue, err := parseHttpURL(rawURL) + _, err = parseHttpURL(rawURL) if err != nil { return fmt.Errorf("server url: %w", err) } - ss.url = urlValue - - headers, err := getHeadersFromEnv(ss.Headers) - if err != nil { - return err - } - ss.headers = headers - return nil } // Validate if the current instance is valid -func (ss ServerConfig) GetURL() (url.URL, error) { - if ss.url != nil { - return *ss.url, nil - } - +func (ss ServerConfig) GetURL() (*url.URL, error) { rawURL, err := ss.URL.Get() if err != nil { - return url.URL{}, err + return nil, err } urlValue, err := parseHttpURL(rawURL) if err != nil { - return url.URL{}, fmt.Errorf("server url: %w", err) + return nil, fmt.Errorf("server url: %w", err) } - return *urlValue, nil -} - -// Validate if the current instance is valid -func (ss ServerConfig) GetHeaders() map[string]string { - if ss.headers != nil { - return ss.headers - } - - return getHeadersFromEnvUnsafe(ss.Headers) + return urlValue, nil } // parseHttpURL parses and validate if the URL has HTTP scheme @@ -164,7 +122,7 @@ func parseHttpURL(input string) (*url.URL, error) { return url.Parse(input) } -func parseRelativeOrHttpURL(input string) (*url.URL, error) { +func ParseRelativeOrHttpURL(input string) (*url.URL, error) { if strings.HasPrefix(input, "/") { return &url.URL{Path: input}, nil } @@ -207,30 +165,3 @@ type TLSConfig struct { func (ss TLSConfig) Validate() error { return nil } - -func getHeadersFromEnv(headers map[string]utils.EnvString) (map[string]string, error) { - results := make(map[string]string) - for key, header := range headers { - value, err := header.Get() - if err != nil { - return nil, fmt.Errorf("headers[%s]: %w", key, err) - } - if value != "" { - results[key] = value - } - } - - return results, nil -} - -func getHeadersFromEnvUnsafe(headers map[string]utils.EnvString) map[string]string { - results := make(map[string]string) - for key, header := range headers { - value, _ := header.Get() - if value != "" { - results[key] = value - } - } - - return results -} diff --git a/ndc-http-schema/schema/setting_test.go b/ndc-http-schema/schema/setting_test.go index 985ffe6..df31153 100644 --- a/ndc-http-schema/schema/setting_test.go +++ b/ndc-http-schema/schema/setting_test.go @@ -74,6 +74,13 @@ func TestNDCHttpSettings(t *testing.T) { "flows": { "implicit": { "authorizationUrl": "https://petstore3.swagger.io/oauth/authorize", + "tokenUrl": { + "value": "https://petstore3.swagger.io/oauth/token" + }, + "refreshUrl": { + "value": "https://petstore3.swagger.io/oauth/token", + "env": "PET_STORE_AUTH_REFRESH_URL" + }, "scopes": { "read:pets": "read your pets", "write:pets": "modify pets in your account" @@ -106,7 +113,6 @@ func TestNDCHttpSettings(t *testing.T) { In: APIKeyInHeader, Name: "api_key", Value: utils.NewEnvStringVariable("PET_STORE_API_KEY"), - value: utils.ToPtr("api_key"), }, }, "basic": { @@ -114,8 +120,6 @@ func TestNDCHttpSettings(t *testing.T) { Type: BasicAuthScheme, Username: utils.NewEnvStringValue("user"), Password: utils.NewEnvStringValue("password"), - username: utils.ToPtr("user"), - password: utils.ToPtr("password"), }, }, "http": { @@ -124,7 +128,6 @@ func TestNDCHttpSettings(t *testing.T) { Header: "Authorization", Scheme: "bearer", Value: utils.NewEnvStringVariable("PET_STORE_API_KEY"), - value: utils.ToPtr("api_key"), }, }, "cookie": { @@ -142,6 +145,8 @@ func TestNDCHttpSettings(t *testing.T) { Flows: map[OAuthFlowType]OAuthFlow{ ImplicitFlow: { AuthorizationURL: "https://petstore3.swagger.io/oauth/authorize", + TokenURL: utils.ToPtr(utils.NewEnvStringValue("https://petstore3.swagger.io/oauth/token")), + RefreshURL: utils.ToPtr(utils.NewEnvString("PET_STORE_AUTH_REFRESH_URL", "https://petstore3.swagger.io/oauth/token")), Scopes: map[string]string{ "read:pets": "read your pets", "write:pets": "modify pets in your account", diff --git a/ndc-http-schema/utils/file.go b/ndc-http-schema/utils/file.go index 82b50a3..e2bb168 100644 --- a/ndc-http-schema/utils/file.go +++ b/ndc-http-schema/utils/file.go @@ -57,12 +57,12 @@ func WriteSchemaFile(outputPath string, content any) error { basePath := filepath.Dir(outputPath) if basePath != "." { - if err := os.MkdirAll(basePath, 0664); err != nil { - return err + if err := os.MkdirAll(basePath, 0o775); err != nil { + return fmt.Errorf("failed to create directory %s: %w", basePath, err) } } - return os.WriteFile(outputPath, rawBytes, 0664) + return os.WriteFile(outputPath, rawBytes, 0o664) } // ReadFileFromPath read file content from either file path or URL diff --git a/ndc-http-schema/utils/string.go b/ndc-http-schema/utils/string.go index b9ba4a4..7d8aea4 100644 --- a/ndc-http-schema/utils/string.go +++ b/ndc-http-schema/utils/string.go @@ -1,6 +1,7 @@ package utils import ( + "fmt" "regexp" "strings" "unicode" @@ -251,3 +252,16 @@ func getu4(s []byte) rune { return r } + +// MaskString masks the string value for security +func MaskString(input string) string { + inputLength := len(input) + switch { + case inputLength < 6: + return strings.Repeat("*", inputLength) + case inputLength < 12: + return input[0:1] + strings.Repeat("*", inputLength-1) + default: + return input[0:3] + strings.Repeat("*", 7) + fmt.Sprintf("(%d)", inputLength) + } +} diff --git a/tests/configuration/config.yaml b/tests/configuration/config.yaml index e169fa2..e6a2e7f 100644 --- a/tests/configuration/config.yaml +++ b/tests/configuration/config.yaml @@ -2,14 +2,15 @@ output: schema.output.json strict: true forwardHeaders: - enabled: false + enabled: true argumentField: headers - responseHeaders: - headersField: "headers" - resultField: "response" - forwardHeaders: - - Content-Type - - X-Custom-Header + responseHeaders: null + # responseHeaders: + # headersField: "headers" + # resultField: "response" + # forwardHeaders: + # - Content-Type + # - X-Custom-Header concurrency: query: 1 mutation: 1 diff --git a/tests/engine/app/metadata/myapi-types.hml b/tests/engine/app/metadata/myapi-types.hml index e46c3bc..f495122 100644 --- a/tests/engine/app/metadata/myapi-types.hml +++ b/tests/engine/app/metadata/myapi-types.hml @@ -74,6 +74,16 @@ definition: graphql: comparisonExpressionTypeName: Int64_comparison_exp +--- +kind: DataConnectorScalarRepresentation +version: v1 +definition: + dataConnectorName: myapi + dataConnectorScalarType: String + representation: String + graphql: + comparisonExpressionTypeName: String_comparison_exp + --- kind: ScalarType version: v1 @@ -112,3 +122,13 @@ definition: graphql: comparisonExpressionTypeName: URI_comparison_exp +--- +kind: DataConnectorScalarRepresentation +version: v1 +definition: + dataConnectorName: myapi + dataConnectorScalarType: Boolean + representation: Boolean + graphql: + comparisonExpressionTypeName: Boolean_comparison_exp + diff --git a/tests/engine/app/metadata/myapi.hml b/tests/engine/app/metadata/myapi.hml index 9602495..1789f5a 100644 --- a/tests/engine/app/metadata/myapi.hml +++ b/tests/engine/app/metadata/myapi.hml @@ -30,6 +30,11 @@ definition: type: int64 aggregate_functions: {} comparison_operators: {} + JSON: + representation: + type: json + aggregate_functions: {} + comparison_operators: {} String: representation: type: string @@ -298,6 +303,13 @@ definition: - name: getAlbums description: Get all available albums arguments: + headers: + description: Headers forwarded from the Hasura engine + type: + type: nullable + underlying_type: + type: named + name: JSON id: description: Filter by album ID type: @@ -320,6 +332,13 @@ definition: - name: getAlbumsId description: Get specific album arguments: + headers: + description: Headers forwarded from the Hasura engine + type: + type: nullable + underlying_type: + type: named + name: JSON id: description: The ID of the album to retrieve type: @@ -331,6 +350,13 @@ definition: - name: getAlbumsIdPhotos description: Get photos for a specific album arguments: + headers: + description: Headers forwarded from the Hasura engine + type: + type: nullable + underlying_type: + type: named + name: JSON id: description: post id type: @@ -344,6 +370,13 @@ definition: - name: getComment description: Get specific comment arguments: + headers: + description: Headers forwarded from the Hasura engine + type: + type: nullable + underlying_type: + type: named + name: JSON id: description: The ID of the comment to retrieve type: @@ -355,6 +388,13 @@ definition: - name: getComments description: Get all available comments arguments: + headers: + description: Headers forwarded from the Hasura engine + type: + type: nullable + underlying_type: + type: named + name: JSON id: description: Filter by comment ID type: @@ -377,6 +417,13 @@ definition: - name: getPhoto description: Get specific photo arguments: + headers: + description: Headers forwarded from the Hasura engine + type: + type: nullable + underlying_type: + type: named + name: JSON id: description: The ID of the photo to retrieve type: @@ -395,6 +442,13 @@ definition: underlying_type: type: named name: Int32 + headers: + description: Headers forwarded from the Hasura engine + type: + type: nullable + underlying_type: + type: named + name: JSON id: description: Filter by photo ID type: @@ -410,6 +464,13 @@ definition: - name: getPostById description: Get specific post arguments: + headers: + description: Headers forwarded from the Hasura engine + type: + type: nullable + underlying_type: + type: named + name: JSON id: description: The ID of the post to retrieve type: @@ -421,6 +482,13 @@ definition: - name: getPosts description: Get all available posts arguments: + headers: + description: Headers forwarded from the Hasura engine + type: + type: nullable + underlying_type: + type: named + name: JSON id: description: Filter by post ID type: @@ -443,6 +511,13 @@ definition: - name: getPostsIdComments description: Get comments for a specific post arguments: + headers: + description: Headers forwarded from the Hasura engine + type: + type: nullable + underlying_type: + type: named + name: JSON id: description: post id type: @@ -456,6 +531,13 @@ definition: - name: getTodo description: Get specific todo arguments: + headers: + description: Headers forwarded from the Hasura engine + type: + type: nullable + underlying_type: + type: named + name: JSON id: description: The ID of the todo to retrieve type: @@ -467,6 +549,13 @@ definition: - name: getTodos description: Get all available todos arguments: + headers: + description: Headers forwarded from the Hasura engine + type: + type: nullable + underlying_type: + type: named + name: JSON id: description: Filter by todo ID type: @@ -489,6 +578,13 @@ definition: - name: getUser description: Get specific user arguments: + headers: + description: Headers forwarded from the Hasura engine + type: + type: nullable + underlying_type: + type: named + name: JSON id: description: The ID of the user to retrieve type: @@ -507,6 +603,13 @@ definition: underlying_type: type: named name: Int32 + headers: + description: Headers forwarded from the Hasura engine + type: + type: nullable + underlying_type: + type: named + name: JSON id: description: Filter by user ID type: @@ -528,12 +631,26 @@ definition: type: type: named name: Post + headers: + description: Headers forwarded from the Hasura engine + type: + type: nullable + underlying_type: + type: named + name: JSON result_type: type: named name: Post - name: deletePostById description: Delete specific post arguments: + headers: + description: Headers forwarded from the Hasura engine + type: + type: nullable + underlying_type: + type: named + name: JSON id: description: The ID of the post to retrieve type: @@ -552,6 +669,13 @@ definition: type: type: named name: Post + headers: + description: Headers forwarded from the Hasura engine + type: + type: nullable + underlying_type: + type: named + name: JSON id: description: The ID of the post to retrieve type: @@ -568,6 +692,13 @@ definition: type: type: named name: Post + headers: + description: Headers forwarded from the Hasura engine + type: + type: nullable + underlying_type: + type: named + name: JSON id: description: The ID of the post to retrieve type: @@ -586,3 +717,17 @@ definition: exists: {} mutation: explain: {} + argumentPresets: + - argument: headers + value: + httpHeaders: + forward: + - X-Test-Header + - X-Custom-Header + additional: {} + responseHeaders: + headersField: headers + resultField: response + forwardHeaders: + - X-Test-Header + - X-Custom-Header diff --git a/tests/engine/globals/metadata/compatibility-config.hml b/tests/engine/globals/metadata/compatibility-config.hml index 10d0471..ca10adf 100644 --- a/tests/engine/globals/metadata/compatibility-config.hml +++ b/tests/engine/globals/metadata/compatibility-config.hml @@ -1,2 +1,2 @@ kind: CompatibilityConfig -date: "2024-10-01" +date: "2024-11-26" diff --git a/tests/hydra.yml b/tests/hydra.yml new file mode 100644 index 0000000..72b8ef7 --- /dev/null +++ b/tests/hydra.yml @@ -0,0 +1,29 @@ +serve: + cookies: + same_site_mode: Lax + +urls: + self: + issuer: http://127.0.0.1:4444 + consent: http://127.0.0.1:3000/consent + login: http://127.0.0.1:3000/login + logout: http://127.0.0.1:3000/logout + +secrets: + system: + - youReallyNeedToChangeThis + +oidc: + dynamic_client_registration: + enabled: true + subject_identifiers: + supported_types: + - pairwise + - public + pairwise: + salt: youReallyNeedToChangeThis + +log: + level: info + format: text + leak_sensitive_values: true