Skip to content

Commit f60a64a

Browse files
feat(source/bigquery): add apiEndpoint for custom API host
Add an optional apiEndpoint (BIGQUERY_ENDPOINT) that overrides the BigQuery API host for proxies, alternate front-ends, and local emulators. The endpoint is applied across all three auth paths (ADC, impersonation, OAuth) and to both the high-level client and the bigquery/v2 REST service. normalizeAPIEndpoint preserves the URL scheme so http-only proxies and emulators (e.g. http://localhost:9050) keep working, defaults a missing scheme to https, appends a default port when absent (:80 for http, else :443), and strips a trailing slash. Dataplex and ask_data_insights use different API surfaces and are intentionally out of scope. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 3854a31 commit f60a64a

8 files changed

Lines changed: 163 additions & 9 deletions

File tree

docs/BIGQUERY_README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ export BIGQUERY_LOCATION="<your-dataset-location>" # Optional
7777
export BIGQUERY_USE_CLIENT_OAUTH="true" # Optional: true, false, or a custom header name
7878
export BIGQUERY_SCOPES="<comma-separated-scopes>" # Optional
7979
export BIGQUERY_IMPERSONATE_SERVICE_ACCOUNT="<service-account-email>" # Optional: Service account to impersonate
80+
export BIGQUERY_ENDPOINT="" # Optional: proxy/emulator URL or host:port (http supported); empty = default Google API
8081
```
8182

8283
Add the following configuration to your MCP client (e.g., `settings.json` for Gemini CLI, `mcp_config.json` for Antigravity):

docs/en/integrations/bigquery/prebuilt-configs/bigquery.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ description: "Details of the BigQuery prebuilt configuration."
1818
to impersonate when making BigQuery and Dataplex API calls. The
1919
authenticated principal must have `roles/iam.serviceAccountTokenCreator`
2020
on the target service account.
21+
* `BIGQUERY_ENDPOINT`: (Optional) Override the BigQuery API host (URL or
22+
`host:port`) for proxies, alternate front-ends, or local emulators.
23+
`http` endpoints are supported (e.g. `http://localhost:9050`). Unset or
24+
empty uses the default Google endpoint. (For the official local emulator,
25+
client libraries use the `BIGQUERY_EMULATOR_HOST` convention.)
2126
* **Permissions:**
2227
* **BigQuery User** (`roles/bigquery.user`) to execute queries and view
2328
metadata.

docs/en/integrations/bigquery/source.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ project: "my-project-id"
114114
# - "https://www.googleapis.com/auth/drive.readonly"
115115
# maxQueryResultRows: 50 # Optional: Limits the number of rows returned by queries. Defaults to 50.
116116
# maximumBytesBilled: 10737418240 # Optional: Per-query bytes scanned cap (in bytes).
117+
# apiEndpoint: "https://my-proxy.example.com" # Optional: Override the BigQuery API host (URL or host:port) for proxies/emulators; http is supported. Unset or empty uses the default Google endpoint.
117118
```
118119

119120
Initialize a BigQuery source that uses the client's access token:
@@ -135,6 +136,7 @@ useClientOAuth: true
135136
# - "https://www.googleapis.com/auth/drive.readonly"
136137
# maxQueryResultRows: 50 # Optional: Limits the number of rows returned by queries. Defaults to 50.
137138
# maximumBytesBilled: 10737418240 # Optional: Per-query bytes scanned cap (in bytes).
139+
# apiEndpoint: "https://my-proxy.example.com" # Optional: Override the BigQuery API host (URL or host:port) for proxies/emulators; http is supported. Unset or empty uses the default Google endpoint.
138140
```
139141

140142
## Reference
@@ -151,3 +153,4 @@ useClientOAuth: true
151153
| impersonateServiceAccount | string | false | Service account email to impersonate when making BigQuery and Dataplex API calls. The authenticated principal must have the `roles/iam.serviceAccountTokenCreator` role on the target service account. [Learn More](https://cloud.google.com/iam/docs/service-account-impersonation) |
152154
| maxQueryResultRows | int | false | The maximum number of rows to return from a query. Defaults to 50. |
153155
| maximumBytesBilled | int64 | false | The maximum bytes billed per query. When set, queries that exceed this limit fail before executing. |
156+
| apiEndpoint | string | false | Overrides the BigQuery API endpoint (URL or `host:port`) for proxies, alternate front-ends, or local emulators. `http` endpoints are supported (e.g. `http://localhost:9050`). Unset or empty uses the default Google endpoint. The scheme is preserved and a default port is added when missing (`80` for http, otherwise `443`). Dataplex and `ask_data_insights` use different API surfaces and are not affected. |

internal/prebuiltconfigs/tools/bigquery.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ useClientOAuth: ${BIGQUERY_USE_CLIENT_OAUTH:false}
2121
scopes: ${BIGQUERY_SCOPES:}
2222
maxQueryResultRows: ${BIGQUERY_MAX_QUERY_RESULT_ROWS:50}
2323
impersonateServiceAccount: ${BIGQUERY_IMPERSONATE_SERVICE_ACCOUNT:}
24+
apiEndpoint: ${BIGQUERY_ENDPOINT:}
2425
---
2526
kind: tool
2627
name: analyze_contribution
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// Copyright 2026 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package bigquery
16+
17+
import (
18+
"strings"
19+
20+
"google.golang.org/api/option"
21+
)
22+
23+
// normalizeAPIEndpoint returns a value accepted by option.WithEndpoint for both
24+
// the bigquery client and the bigquery/v2 REST service. Empty/whitespace yields
25+
// "" (default Google endpoint). The scheme is preserved so http-only proxies and
26+
// emulators (e.g. http://localhost:9050) keep working; a missing scheme defaults
27+
// to https. A default port is appended when absent (:80 for http, else :443), and
28+
// a trailing slash is removed.
29+
func normalizeAPIEndpoint(raw string) string {
30+
s := strings.TrimSpace(raw)
31+
if s == "" {
32+
return ""
33+
}
34+
scheme := "https://"
35+
for _, p := range []string{"https://", "http://"} {
36+
if len(s) >= len(p) && strings.EqualFold(s[:len(p)], p) {
37+
scheme, s = p, s[len(p):]
38+
break
39+
}
40+
}
41+
host := strings.TrimSuffix(s, "/")
42+
if !strings.Contains(host, ":") {
43+
if scheme == "http://" {
44+
host += ":80"
45+
} else {
46+
host += ":443"
47+
}
48+
}
49+
return scheme + host
50+
}
51+
52+
func appendAPIEndpointOption(opts []option.ClientOption, apiEndpoint string) []option.ClientOption {
53+
if ep := normalizeAPIEndpoint(apiEndpoint); ep != "" {
54+
return append(opts, option.WithEndpoint(ep))
55+
}
56+
return opts
57+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// Copyright 2026 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package bigquery
16+
17+
import (
18+
"testing"
19+
20+
"google.golang.org/api/option"
21+
)
22+
23+
func TestNormalizeAPIEndpoint(t *testing.T) {
24+
tcs := []struct {
25+
in string
26+
want string
27+
}{
28+
{"", ""},
29+
{" ", ""},
30+
{"https://proxy.example.com", "https://proxy.example.com:443"},
31+
{"https://proxy.example.com/", "https://proxy.example.com:443"},
32+
{"http://proxy.example.com", "http://proxy.example.com:80"},
33+
{"http://localhost:9050", "http://localhost:9050"},
34+
{"proxy.example.com", "https://proxy.example.com:443"},
35+
{"proxy.example.com:8443", "https://proxy.example.com:8443"},
36+
}
37+
for _, tc := range tcs {
38+
t.Run(tc.in, func(t *testing.T) {
39+
if got := normalizeAPIEndpoint(tc.in); got != tc.want {
40+
t.Fatalf("normalizeAPIEndpoint(%q) = %q, want %q", tc.in, got, tc.want)
41+
}
42+
})
43+
}
44+
}
45+
46+
func TestAppendAPIEndpointOption(t *testing.T) {
47+
base := []option.ClientOption{option.WithUserAgent("test")}
48+
49+
if got := appendAPIEndpointOption(base, ""); len(got) != len(base) {
50+
t.Fatalf("empty endpoint: got %d options, want %d", len(got), len(base))
51+
}
52+
if got := appendAPIEndpointOption(base, "https://proxy.example.com"); len(got) != len(base)+1 {
53+
t.Fatalf("set endpoint: got %d options, want %d", len(got), len(base)+1)
54+
}
55+
}

internal/sources/bigquery/bigquery.go

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,10 @@ type Config struct {
9292
Scopes StringOrStringSlice `yaml:"scopes"`
9393
MaxQueryResultRows int `yaml:"maxQueryResultRows"`
9494
MaximumBytesBilled int64 `yaml:"maximumBytesBilled" validate:"gte=0"`
95+
// ApiEndpoint overrides the BigQuery API host (URL or host:port) for proxies
96+
// and emulators. Empty uses the default Google endpoint; http endpoints are
97+
// supported.
98+
ApiEndpoint string `yaml:"apiEndpoint"`
9599
}
96100

97101
// StringOrStringSlice is a custom type that can unmarshal both a single string
@@ -166,7 +170,7 @@ func (r Config) Initialize(ctx context.Context, tracer trace.Tracer) (sources.So
166170

167171
if strings.ToLower(r.UseClientOAuth) == "false" || r.UseClientOAuth == "" {
168172
// Initializes a BigQuery Google SQL source
169-
client, restService, tokenSource, err = initBigQueryConnection(ctx, tracer, r.Name, r.Project, r.Location, r.ImpersonateServiceAccount, r.Scopes)
173+
client, restService, tokenSource, err = initBigQueryConnection(ctx, tracer, r.Name, r.Project, r.Location, r.ImpersonateServiceAccount, r.Scopes, r.ApiEndpoint)
170174
if err != nil {
171175
return nil, fmt.Errorf("error creating client from ADC: %w", err)
172176
}
@@ -183,7 +187,7 @@ func (r Config) Initialize(ctx context.Context, tracer trace.Tracer) (sources.So
183187
s.AuthTokenHeaderName = r.UseClientOAuth
184188
}
185189
// use client OAuth
186-
baseClientCreator, err := newBigQueryClientCreator(ctx, tracer, r.Project, r.Location, r.Name)
190+
baseClientCreator, err := newBigQueryClientCreator(ctx, tracer, r.Project, r.Location, r.Name, r.ApiEndpoint)
187191
if err != nil {
188192
return nil, fmt.Errorf("error constructing client creator: %w", err)
189193
}
@@ -689,6 +693,7 @@ func initBigQueryConnection(
689693
location string,
690694
impersonateServiceAccount string,
691695
scopes []string,
696+
apiEndpoint string,
692697
) (*bigqueryapi.Client, *bigqueryrestapi.Service, oauth2.TokenSource, error) {
693698
ctx, span := sources.InitConnectionSpan(ctx, tracer, SourceType, name)
694699
defer span.End()
@@ -721,21 +726,21 @@ func initBigQueryConnection(
721726
return nil, nil, nil, fmt.Errorf("failed to create impersonated credentials for %q: %w", impersonateServiceAccount, err)
722727
}
723728
tokenSource = cloudPlatformTokenSource
724-
opts = []option.ClientOption{
729+
opts = appendAPIEndpointOption([]option.ClientOption{
725730
option.WithUserAgent(userAgent),
726731
option.WithTokenSource(cloudPlatformTokenSource),
727-
}
732+
}, apiEndpoint)
728733
} else {
729734
// Use default credentials
730735
cred, err := google.FindDefaultCredentials(ctx, credScopes...)
731736
if err != nil {
732737
return nil, nil, nil, fmt.Errorf("failed to find default Google Cloud credentials with scopes %v: %w", credScopes, err)
733738
}
734739
tokenSource = cred.TokenSource
735-
opts = []option.ClientOption{
740+
opts = appendAPIEndpointOption([]option.ClientOption{
736741
option.WithUserAgent(userAgent),
737742
option.WithCredentials(cred),
738-
}
743+
}, apiEndpoint)
739744
}
740745

741746
// Initialize the high-level BigQuery client
@@ -765,6 +770,7 @@ func initBigQueryConnectionWithOAuthToken(
765770
userAgent string,
766771
tokenString string,
767772
wantRestService bool,
773+
apiEndpoint string,
768774
) (*bigqueryapi.Client, *bigqueryrestapi.Service, error) {
769775
ctx, span := sources.InitConnectionSpan(ctx, tracer, SourceType, name)
770776
defer span.End()
@@ -774,16 +780,21 @@ func initBigQueryConnectionWithOAuthToken(
774780
}
775781
ts := oauth2.StaticTokenSource(token)
776782

783+
oauthOpts := appendAPIEndpointOption([]option.ClientOption{
784+
option.WithUserAgent(userAgent),
785+
option.WithTokenSource(ts),
786+
}, apiEndpoint)
787+
777788
// Initialize the BigQuery client with tokenSource
778-
client, err := bigqueryapi.NewClient(ctx, project, option.WithUserAgent(userAgent), option.WithTokenSource(ts))
789+
client, err := bigqueryapi.NewClient(ctx, project, oauthOpts...)
779790
if err != nil {
780791
return nil, nil, fmt.Errorf("failed to create BigQuery client for project %q: %w", project, err)
781792
}
782793
client.Location = location
783794

784795
if wantRestService {
785796
// Initialize the low-level BigQuery REST service using the same credentials
786-
restService, err := bigqueryrestapi.NewService(ctx, option.WithUserAgent(userAgent), option.WithTokenSource(ts))
797+
restService, err := bigqueryrestapi.NewService(ctx, oauthOpts...)
787798
if err != nil {
788799
return nil, nil, fmt.Errorf("failed to create BigQuery v2 service: %w", err)
789800
}
@@ -802,17 +813,20 @@ func newBigQueryClientCreator(
802813
project string,
803814
location string,
804815
name string,
816+
apiEndpoint string,
805817
) (func(string, bool) (*bigqueryapi.Client, *bigqueryrestapi.Service, error), error) {
806818
userAgent, err := util.UserAgentFromContext(ctx)
807819
if err != nil {
808820
return nil, err
809821
}
810822

811823
return func(tokenString string, wantRestService bool) (*bigqueryapi.Client, *bigqueryrestapi.Service, error) {
812-
return initBigQueryConnectionWithOAuthToken(ctx, tracer, project, location, name, userAgent, tokenString, wantRestService)
824+
return initBigQueryConnectionWithOAuthToken(ctx, tracer, project, location, name, userAgent, tokenString, wantRestService, apiEndpoint)
813825
}, nil
814826
}
815827

828+
// apiEndpoint is intentionally not applied here: Dataplex (catalog search) and
829+
// ask_data_insights use different API surfaces and are out of scope for this override.
816830
func initDataplexConnection(
817831
ctx context.Context,
818832
tracer trace.Tracer,

internal/sources/bigquery/bigquery_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,24 @@ func TestParseFromYamlBigQuery(t *testing.T) {
257257
},
258258
},
259259
},
260+
{
261+
desc: "with api endpoint example",
262+
in: `
263+
kind: source
264+
name: my-instance
265+
type: bigquery
266+
project: my-project
267+
apiEndpoint: https://proxy.example.com
268+
`,
269+
want: map[string]sources.SourceConfig{
270+
"my-instance": bigquery.Config{
271+
Name: "my-instance",
272+
Type: bigquery.SourceType,
273+
Project: "my-project",
274+
ApiEndpoint: "https://proxy.example.com",
275+
},
276+
},
277+
},
260278
}
261279
for _, tc := range tcs {
262280
t.Run(tc.desc, func(t *testing.T) {

0 commit comments

Comments
 (0)