Skip to content

Commit b8efb20

Browse files
julianknutsenclaude
andcommitted
Switch DoltHub fork from REST API to GraphQL createFork mutation
Replace the v1alpha1 REST fork endpoint with the DoltHub GraphQL API at /graphql, using the createFork mutation with cookie-based auth (dolthubToken). The GraphQL API is more reliable and is the same approach used by hosted.doltdb.com per DoltHub team guidance. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0f0f12c commit b8efb20

File tree

2 files changed

+96
-36
lines changed

2 files changed

+96
-36
lines changed

internal/remote/dolthub.go

Lines changed: 51 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,15 @@ import (
44
"bytes"
55
"encoding/json"
66
"fmt"
7+
"io"
78
"net/http"
89
"strings"
910
"time"
1011
)
1112

12-
// dolthubAPIBase is the DoltHub REST API base URL.
13+
// dolthubGraphQLURL is the DoltHub GraphQL API endpoint.
1314
// Var so tests can override it.
14-
var dolthubAPIBase = "https://www.dolthub.com/api/v1alpha1"
15+
var dolthubGraphQLURL = "https://www.dolthub.com/graphql"
1516

1617
const dolthubRemoteBase = "https://doltremoteapi.dolthub.com"
1718

@@ -29,48 +30,77 @@ func (d *DoltHubProvider) DatabaseURL(org, db string) string {
2930
return fmt.Sprintf("%s/%s/%s", dolthubRemoteBase, org, db)
3031
}
3132

33+
// graphqlRequest is the JSON body sent to the GraphQL endpoint.
34+
type graphqlRequest struct {
35+
Query string `json:"query"`
36+
Variables map[string]any `json:"variables,omitempty"`
37+
}
38+
39+
// graphqlResponse is the top-level JSON response from GraphQL.
40+
type graphqlResponse struct {
41+
Data json.RawMessage `json:"data"`
42+
Errors []struct {
43+
Message string `json:"message"`
44+
} `json:"errors"`
45+
}
46+
3247
func (d *DoltHubProvider) Fork(fromOrg, fromDB, toOrg string) error {
33-
body := map[string]string{
34-
"owner_name": toOrg,
35-
"new_repo_name": fromDB,
36-
"from_owner": fromOrg,
37-
"from_repo_name": fromDB,
48+
query := `mutation CreateFork($ownerName: String!, $parentOwnerName: String!, $parentRepoName: String!) {
49+
createFork(ownerName: $ownerName, parentOwnerName: $parentOwnerName, parentRepoName: $parentRepoName) {
50+
forkOperationName
51+
}
52+
}`
53+
reqBody := graphqlRequest{
54+
Query: query,
55+
Variables: map[string]any{
56+
"ownerName": toOrg,
57+
"parentOwnerName": fromOrg,
58+
"parentRepoName": fromDB,
59+
},
3860
}
39-
payload, err := json.Marshal(body)
61+
payload, err := json.Marshal(reqBody)
4062
if err != nil {
4163
return fmt.Errorf("marshaling fork request: %w", err)
4264
}
4365

44-
url := dolthubAPIBase + "/database/fork"
45-
req, err := http.NewRequest("POST", url, bytes.NewReader(payload))
66+
req, err := http.NewRequest("POST", dolthubGraphQLURL, bytes.NewReader(payload))
4667
if err != nil {
4768
return fmt.Errorf("creating fork request: %w", err)
4869
}
4970
req.Header.Set("Content-Type", "application/json")
50-
req.Header.Set("authorization", "token "+d.token)
71+
req.Header.Set("Cookie", "dolthubToken="+d.token)
5172

5273
client := &http.Client{Timeout: 60 * time.Second}
5374
resp, err := client.Do(req)
5475
if err != nil {
55-
return fmt.Errorf("DoltHub fork API request failed: %w", err)
76+
return fmt.Errorf("DoltHub GraphQL fork request failed: %w", err)
5677
}
5778
defer func() { _ = resp.Body.Close() }()
5879

59-
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
60-
return nil
80+
body, err := io.ReadAll(resp.Body)
81+
if err != nil {
82+
return fmt.Errorf("reading DoltHub GraphQL response: %w", err)
6183
}
6284

63-
var errResp struct {
64-
Status string `json:"status"`
65-
Message string `json:"message"`
85+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
86+
return fmt.Errorf("DoltHub GraphQL error (HTTP %d): %s", resp.StatusCode, string(body))
87+
}
88+
89+
var gqlResp graphqlResponse
90+
if err := json.Unmarshal(body, &gqlResp); err != nil {
91+
return fmt.Errorf("parsing DoltHub GraphQL response: %w", err)
6692
}
67-
if decErr := json.NewDecoder(resp.Body).Decode(&errResp); decErr == nil {
68-
if strings.Contains(strings.ToLower(errResp.Message), "already exists") {
93+
94+
if len(gqlResp.Errors) > 0 {
95+
msg := gqlResp.Errors[0].Message
96+
if strings.Contains(strings.ToLower(msg), "already exists") ||
97+
strings.Contains(strings.ToLower(msg), "already been forked") {
6998
return nil
7099
}
71-
return fmt.Errorf("DoltHub fork API error (HTTP %d): %s", resp.StatusCode, errResp.Message)
100+
return fmt.Errorf("DoltHub GraphQL fork error: %s", msg)
72101
}
73-
return fmt.Errorf("DoltHub fork API error (HTTP %d)", resp.StatusCode)
102+
103+
return nil
74104
}
75105

76106
func (d *DoltHubProvider) Type() string { return "dolthub" }

internal/remote/dolthub_test.go

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"encoding/json"
55
"net/http"
66
"net/http/httptest"
7+
"strings"
78
"testing"
89
)
910

@@ -14,9 +15,9 @@ func TestDoltHubProvider_Fork(t *testing.T) {
1415
body string
1516
wantError bool
1617
}{
17-
{"success", 200, `{"status":"ok"}`, false},
18-
{"already exists", 409, `{"message":"already exists"}`, false},
19-
{"forbidden", 403, `{"message":"forbidden"}`, true},
18+
{"success", 200, `{"data":{"createFork":{"forkOperationName":"op-123"}}}`, false},
19+
{"already exists", 200, `{"errors":[{"message":"database has already been forked"}]}`, false},
20+
{"forbidden", 200, `{"errors":[{"message":"forbidden"}]}`, true},
2021
}
2122

2223
for _, tt := range tests {
@@ -25,29 +26,40 @@ func TestDoltHubProvider_Fork(t *testing.T) {
2526
if r.Method != "POST" {
2627
t.Errorf("expected POST, got %s", r.Method)
2728
}
28-
if r.URL.Path != "/database/fork" {
29-
t.Errorf("expected /database/fork, got %s", r.URL.Path)
30-
}
31-
if r.Header.Get("authorization") != "token test-token" {
32-
t.Errorf("expected auth header, got %q", r.Header.Get("authorization"))
29+
30+
// Verify cookie auth.
31+
cookie := r.Header.Get("Cookie")
32+
if !strings.Contains(cookie, "dolthubToken=test-token") {
33+
t.Errorf("expected dolthubToken cookie, got %q", cookie)
3334
}
3435

35-
var body map[string]string
36-
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
36+
// Verify GraphQL request body.
37+
var gqlReq graphqlRequest
38+
if err := json.NewDecoder(r.Body).Decode(&gqlReq); err != nil {
3739
t.Errorf("decoding request body: %v", err)
3840
}
39-
if body["from_owner"] != "steveyegge" {
40-
t.Errorf("from_owner = %q, want %q", body["from_owner"], "steveyegge")
41+
if !strings.Contains(gqlReq.Query, "createFork") {
42+
t.Errorf("query should contain createFork, got %q", gqlReq.Query)
43+
}
44+
vars := gqlReq.Variables
45+
if vars["parentOwnerName"] != "steveyegge" {
46+
t.Errorf("parentOwnerName = %q, want %q", vars["parentOwnerName"], "steveyegge")
47+
}
48+
if vars["parentRepoName"] != "wl-commons" {
49+
t.Errorf("parentRepoName = %q, want %q", vars["parentRepoName"], "wl-commons")
50+
}
51+
if vars["ownerName"] != "alice-dev" {
52+
t.Errorf("ownerName = %q, want %q", vars["ownerName"], "alice-dev")
4153
}
4254

4355
w.WriteHeader(tt.statusCode)
4456
_, _ = w.Write([]byte(tt.body))
4557
}))
4658
defer server.Close()
4759

48-
oldBase := dolthubAPIBase
49-
dolthubAPIBase = server.URL
50-
defer func() { dolthubAPIBase = oldBase }()
60+
oldURL := dolthubGraphQLURL
61+
dolthubGraphQLURL = server.URL
62+
defer func() { dolthubGraphQLURL = oldURL }()
5163

5264
provider := NewDoltHubProvider("test-token")
5365
err := provider.Fork("steveyegge", "wl-commons", "alice-dev")
@@ -61,6 +73,24 @@ func TestDoltHubProvider_Fork(t *testing.T) {
6173
}
6274
}
6375

76+
func TestDoltHubProvider_Fork_HTTPError(t *testing.T) {
77+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
78+
w.WriteHeader(500)
79+
_, _ = w.Write([]byte("internal server error"))
80+
}))
81+
defer server.Close()
82+
83+
oldURL := dolthubGraphQLURL
84+
dolthubGraphQLURL = server.URL
85+
defer func() { dolthubGraphQLURL = oldURL }()
86+
87+
provider := NewDoltHubProvider("test-token")
88+
err := provider.Fork("org", "db", "fork-org")
89+
if err == nil {
90+
t.Error("expected error for HTTP 500")
91+
}
92+
}
93+
6494
func TestDoltHubProvider_DatabaseURL(t *testing.T) {
6595
provider := NewDoltHubProvider("token")
6696
got := provider.DatabaseURL("steveyegge", "wl-commons")

0 commit comments

Comments
 (0)