├── models ├── error.go ├── cancel.go ├── pipeline.go ├── usage.go ├── results_test.go ├── execute.go ├── datasets.go ├── status.go ├── uploads.go └── results.go ├── .golangci.yml ├── go.mod ├── .gitignore ├── makefile ├── dune ├── pipeline.go ├── http.go ├── execution.go └── dune.go ├── go.sum ├── LICENSE ├── config └── config.go ├── e2e ├── README.md ├── datasets_test.go └── uploads_test.go ├── cmd └── main.go └── README.md /models/error.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type ExecutionError struct { 4 | Type string `json:"type"` 5 | Message string `json:"message"` 6 | Metadata map[string]interface{} `json:"metadata,omitempty"` 7 | } 8 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | linters: 3 | enable: 4 | - goimports 5 | - stylecheck 6 | - lll 7 | disable: 8 | - errcheck 9 | 10 | run: 11 | go: '1.21' 12 | 13 | issues: 14 | exclude-rules: 15 | - linters: 16 | - lll 17 | source: "// nolint:lll" 18 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/duneanalytics/duneapi-client-go 2 | 3 | go 1.22 4 | 5 | require github.com/stretchr/testify v1.8.4 6 | 7 | require ( 8 | github.com/davecgh/go-spew v1.1.1 // indirect 9 | github.com/pmezard/go-difflib v1.0.0 // indirect 10 | gopkg.in/yaml.v3 v3.0.1 // indirect 11 | ) 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Local tools 15 | /bin/ 16 | 17 | dunecli 18 | -------------------------------------------------------------------------------- /models/cancel.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "errors" 5 | ) 6 | 7 | type CancelResponse struct { 8 | Success bool `json:"success"` 9 | } 10 | 11 | func (r CancelResponse) HasError() error { 12 | if !r.Success { 13 | return errors.New("failed to cancel query execution") 14 | } 15 | 16 | return nil 17 | } 18 | -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all setup dunecli build lint yamllint 2 | 3 | all: lint test build 4 | 5 | setup: bin/golangci-lint 6 | go mod download 7 | 8 | dunecli: lint 9 | go build -o dunecli cmd/main.go 10 | 11 | build: dunecli 12 | 13 | bin: 14 | mkdir -p bin 15 | 16 | bin/golangci-lint: bin 17 | GOBIN=$(PWD)/bin go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.57.2 18 | 19 | lint: bin/golangci-lint 20 | go fmt ./... 21 | go vet ./... 22 | bin/golangci-lint -c .golangci.yml run ./... 23 | go mod tidy 24 | 25 | test: 26 | go test -timeout=10s -race -cover -bench=. -benchmem ./... 27 | -------------------------------------------------------------------------------- /dune/pipeline.go: -------------------------------------------------------------------------------- 1 | package dune 2 | 3 | import ( 4 | "github.com/duneanalytics/duneapi-client-go/models" 5 | ) 6 | 7 | type pipeline struct { 8 | client DuneClient 9 | ID string 10 | } 11 | 12 | type Pipeline interface { 13 | GetStatus() (*models.PipelineStatusResponse, error) 14 | GetID() string 15 | } 16 | 17 | func NewPipeline(client DuneClient, ID string) *pipeline { 18 | return &pipeline{ 19 | client: client, 20 | ID: ID, 21 | } 22 | } 23 | 24 | func (p *pipeline) GetStatus() (*models.PipelineStatusResponse, error) { 25 | return p.client.PipelineStatus(p.ID) 26 | } 27 | 28 | func (p *pipeline) GetID() string { 29 | return p.ID 30 | } 31 | -------------------------------------------------------------------------------- /models/pipeline.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type PipelineQueryExecutionStatus struct { 4 | Status string `json:"status,omitempty"` 5 | QueryID int `json:"query_id,omitempty"` 6 | ExecutionID string `json:"execution_id,omitempty"` 7 | } 8 | 9 | type PipelineNodeExecution struct { 10 | ID int `json:"id,omitempty"` 11 | QueryExecutionStatus PipelineQueryExecutionStatus `json:"query_execution_status,omitempty"` 12 | } 13 | 14 | type PipelineStatusResponse struct { 15 | Status string `json:"status,omitempty"` 16 | NodeExecutions []PipelineNodeExecution `json:"node_executions,omitempty"` 17 | } 18 | -------------------------------------------------------------------------------- /models/usage.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type UsageRequest struct { 4 | StartDate *string `json:"start_date,omitempty"` 5 | EndDate *string `json:"end_date,omitempty"` 6 | } 7 | 8 | type BillingPeriod struct { 9 | StartDate string `json:"start_date"` 10 | EndDate string `json:"end_date"` 11 | CreditsUsed float64 `json:"credits_used"` 12 | CreditsIncluded int `json:"credits_included"` 13 | } 14 | 15 | type UsageResponse struct { 16 | PrivateQueries int `json:"private_queries"` 17 | PrivateDashboards int `json:"private_dashboards"` 18 | BytesUsed int64 `json:"bytes_used"` 19 | BytesAllowed int64 `json:"bytes_allowed"` 20 | BillingPeriods []BillingPeriod `json:"billing_periods"` 21 | } 22 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 4 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 5 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 6 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 7 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 8 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 9 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 10 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 11 | -------------------------------------------------------------------------------- /models/results_test.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestResultOptions(t *testing.T) { 10 | require.Equal(t, "limit=32000", ResultOptions{}.ToURLValues().Encode()) 11 | } 12 | 13 | func TestResultAddPage(t *testing.T) { 14 | r := ResultsResponse{} 15 | r.AddPageResult(&ResultsResponse{ 16 | QueryID: 1, 17 | State: "state", 18 | Result: Result{ 19 | Metadata: ResultMetadata{ 20 | ResultSetBytes: 1, 21 | RowCount: 1, 22 | DatapointCount: 1, 23 | TotalRowCount: 2, 24 | }, 25 | Rows: []map[string]any{ 26 | {"a": 1}, 27 | }, 28 | }, 29 | }) 30 | require.Equal(t, int64(1), r.QueryID) 31 | require.Equal(t, "state", r.State) 32 | require.Equal(t, 1, r.Result.Metadata.RowCount) 33 | require.Equal(t, 2, r.Result.Metadata.TotalRowCount) 34 | require.Equal(t, 1, len(r.Result.Rows)) 35 | } 36 | -------------------------------------------------------------------------------- /models/execute.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | type ExecuteRequest struct { 9 | QueryParameters map[string]any `json:"query_parameters,omitempty"` 10 | } 11 | 12 | type ExecuteSQLRequest struct { 13 | SQL string `json:"sql"` 14 | Performance string `json:"performance,omitempty"` 15 | } 16 | 17 | type ExecuteResponse struct { 18 | ExecutionID string `json:"execution_id,omitempty"` 19 | State string `json:"state,omitempty"` 20 | } 21 | 22 | func (e ExecuteResponse) HasError() error { 23 | // 01 is the expected prefix for an ULID string 24 | if len(e.ExecutionID) != 26 || !strings.HasPrefix(e.ExecutionID, "01") { 25 | return fmt.Errorf("bad execution id: %v", e.ExecutionID) 26 | } 27 | if !strings.HasPrefix(e.State, "QUERY_STATE_") { 28 | return fmt.Errorf("bad state: %v", e.State) 29 | } 30 | return nil 31 | } 32 | 33 | type PipelineExecuteRequest struct { 34 | Performance string `json:"performance,omitempty"` 35 | } 36 | 37 | type PipelineExecuteResponse struct { 38 | PipelineExecutionID string `json:"pipeline_execution_id,omitempty"` 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Dune 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | const DefaultHost = "https://api.dune.com" 9 | 10 | type Env struct { 11 | APIKey string 12 | Host string 13 | } 14 | 15 | func getenvOrDefault(key string, defaultValue string) string { 16 | value, found := os.LookupEnv(key) 17 | if found { 18 | return value 19 | } 20 | 21 | return defaultValue 22 | } 23 | 24 | func getenvOrError(key string) (string, error) { 25 | value, found := os.LookupEnv(key) 26 | if found { 27 | return value, nil 28 | } 29 | 30 | return "", fmt.Errorf("environment variable %s must be set", key) 31 | } 32 | 33 | // FromEnvVars populates the config from environment variables 34 | func FromEnvVars() (*Env, error) { 35 | apiKey, err := getenvOrError("DUNE_API_KEY") 36 | if err != nil { 37 | return nil, err 38 | } 39 | host := getenvOrDefault("DUNE_API_HOST", DefaultHost) 40 | 41 | return &Env{ 42 | APIKey: apiKey, 43 | Host: host, 44 | }, nil 45 | } 46 | 47 | // FromAPIKey generates the config from a passed API key. Uses the default Host 48 | func FromAPIKey(apiKey string) *Env { 49 | return &Env{ 50 | APIKey: apiKey, 51 | Host: DefaultHost, 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /dune/http.go: -------------------------------------------------------------------------------- 1 | package dune 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "net/http" 8 | ) 9 | 10 | var ErrorReqUnsuccessful = errors.New("request was not successful") 11 | 12 | type ErrorResponse struct { 13 | Error string `json:"error"` 14 | } 15 | 16 | func decodeBody(resp *http.Response, dest interface{}) error { 17 | defer resp.Body.Close() 18 | err := json.NewDecoder(resp.Body).Decode(dest) 19 | if err != nil { 20 | return fmt.Errorf("failed to parse response: %w", err) 21 | } 22 | return nil 23 | } 24 | 25 | func httpRequest(apiKey string, req *http.Request) (*http.Response, error) { 26 | req.Header.Add("X-DUNE-API-KEY", apiKey) 27 | resp, err := http.DefaultClient.Do(req) 28 | if err != nil { 29 | return nil, fmt.Errorf("failed to send request: %w", err) 30 | } 31 | 32 | if resp.StatusCode < 200 || resp.StatusCode >= 300 { 33 | defer resp.Body.Close() 34 | var errorResponse ErrorResponse 35 | err := json.NewDecoder(resp.Body).Decode(&errorResponse) 36 | if err != nil { 37 | return nil, fmt.Errorf("failed to read error response body: %w", err) 38 | } 39 | return resp, fmt.Errorf("%w [%d]: %s", ErrorReqUnsuccessful, resp.StatusCode, errorResponse.Error) 40 | } 41 | 42 | return resp, nil 43 | } 44 | -------------------------------------------------------------------------------- /e2e/README.md: -------------------------------------------------------------------------------- 1 | # End-to-End Tests 2 | 3 | This directory contains E2E tests that hit the real Dune API. 4 | 5 | ## Running the tests 6 | 7 | Run all E2E tests: 8 | ```bash 9 | go test ./e2e/... -v -timeout 120s 10 | ``` 11 | 12 | Run only upload tests: 13 | ```bash 14 | go test ./e2e/... -v -run TestUpload -timeout 60s 15 | ``` 16 | 17 | Run only dataset tests: 18 | ```bash 19 | go test ./e2e/... -v -run TestDataset -timeout 60s 20 | ``` 21 | 22 | Skip E2E tests in short mode: 23 | ```bash 24 | go test ./e2e/... -short 25 | ``` 26 | 27 | ## Environment Variables 28 | 29 | **Required environment variables** (tests will fail fast if not set): 30 | 31 | - `DUNE_API_KEY` - Dune API key 32 | - `DUNE_API_KEY_OWNER_HANDLE` - Namespace for table operations 33 | 34 | Example: 35 | ```bash 36 | export DUNE_API_KEY=your_api_key_here 37 | export DUNE_API_KEY_OWNER_HANDLE=your_namespace 38 | go test ./e2e/... -v 39 | ``` 40 | 41 | Or inline: 42 | ```bash 43 | DUNE_API_KEY=your_key DUNE_API_KEY_OWNER_HANDLE=your_namespace go test ./e2e/... -v 44 | ``` 45 | 46 | ## Notes 47 | 48 | - Tests create and cleanup their own test tables 49 | - Table names are timestamped to avoid conflicts: `test_uploads_api_{timestamp}` 50 | - Tests require a Plus subscription for upload operations 51 | - Datasets API tests only require a valid API key 52 | -------------------------------------------------------------------------------- /models/datasets.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | type DatasetColumn struct { 8 | Name string `json:"name"` 9 | Type string `json:"type"` 10 | Nullable bool `json:"nullable"` 11 | Description string `json:"description,omitempty"` 12 | Metadata map[string]any `json:"metadata,omitempty"` 13 | } 14 | 15 | type DatasetOwner struct { 16 | Handle string `json:"handle"` 17 | Type string `json:"type"` 18 | } 19 | 20 | type DatasetResponse struct { 21 | Type string `json:"type"` 22 | FullName string `json:"full_name"` 23 | IsPrivate bool `json:"is_private"` 24 | Columns []DatasetColumn `json:"columns"` 25 | Owner *DatasetOwner `json:"owner"` 26 | Metadata map[string]any `json:"metadata,omitempty"` 27 | CreatedAt string `json:"created_at"` 28 | UpdatedAt string `json:"updated_at"` 29 | } 30 | 31 | func (d DatasetResponse) HasError() error { 32 | if d.FullName == "" { 33 | return fmt.Errorf("missing full_name") 34 | } 35 | if d.Type == "" { 36 | return fmt.Errorf("missing type") 37 | } 38 | return nil 39 | } 40 | 41 | type ListDatasetsResponse struct { 42 | Datasets []DatasetResponse `json:"datasets"` 43 | Total int `json:"total"` 44 | } 45 | 46 | func (l ListDatasetsResponse) HasError() error { 47 | if l.Datasets == nil { 48 | return fmt.Errorf("missing datasets array") 49 | } 50 | return nil 51 | } 52 | -------------------------------------------------------------------------------- /models/status.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strings" 7 | "time" 8 | ) 9 | 10 | type StatusResponse struct { 11 | ExecutionID string `json:"execution_id,omitempty"` 12 | QueryID int `json:"query_id,omitempty"` 13 | State string `json:"state,omitempty"` 14 | SubmittedAt time.Time `json:"submitted_at,omitempty"` 15 | ExecutionStartedAt *time.Time `json:"execution_started_at,omitempty"` 16 | ExecutionEndedAt *time.Time `json:"execution_ended_at,omitempty"` 17 | CancelledAt *time.Time `json:"cancelled_at,omitempty"` 18 | Error *ExecutionError `json:"error,omitempty"` 19 | ResultMetadata *ResultMetadata `json:"result_metadata,omitempty"` 20 | } 21 | 22 | func (s StatusResponse) HasError() error { 23 | if s.ExecutionID == "" { 24 | return errors.New("missing executionID") 25 | } 26 | if !strings.HasPrefix(s.State, "QUERY_STATE_") { 27 | return fmt.Errorf("bad state: %v", s.State) 28 | } 29 | 30 | if s.State == "QUERY_STATE_COMPLETED" { 31 | if s.ResultMetadata == nil { 32 | return errors.New("missing results metadata") 33 | } 34 | if s.ExecutionEndedAt == nil { 35 | return errors.New("missing execution endedAt") 36 | } 37 | } else { 38 | if s.ResultMetadata != nil { 39 | return fmt.Errorf("cannot have results metadata if state: %v", s.State) 40 | } 41 | } 42 | 43 | if s.State == "QUERY_STATE_CANCELLED" { 44 | if s.CancelledAt == nil { 45 | return errors.New("missing cancelled at") 46 | } 47 | } else { 48 | if s.CancelledAt != nil { 49 | return errors.New("field CancelledAt shouldn't be present") 50 | } 51 | } 52 | return nil 53 | } 54 | -------------------------------------------------------------------------------- /cmd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "flag" 6 | "fmt" 7 | "os" 8 | "time" 9 | 10 | "github.com/duneanalytics/duneapi-client-go/config" 11 | "github.com/duneanalytics/duneapi-client-go/dune" 12 | ) 13 | 14 | func main() { 15 | queryID := flag.Int("q", 0, "The ID of the query to execute. Conflicts with -e") 16 | queryParametersStr := flag.String("p", "{}", "Parameters to pass to the query in JSON format") 17 | executionID := flag.String("e", "", "ID of an existing execution to check status. Conflicts with -q") 18 | maxRetries := flag.Int("max-retries", 5, "Max number of errors tolerated before giving up") 19 | pollInterval := flag.Duration("poll-interval", 5*time.Second, "Interval in seconds for polling for results") 20 | 21 | flag.Parse() 22 | 23 | // Guards against providing either both a query and execution ID or neither. 24 | if (*executionID == "" && *queryID == 0) || (*executionID != "" && *queryID != 0) { 25 | fmt.Fprintln(os.Stderr, "must provide exactly one of ExecutionID and QueryID") 26 | os.Exit(1) 27 | } 28 | 29 | // Load the API key config from the DUNE_API_KEY environment variable 30 | env, err := config.FromEnvVars() 31 | if err != nil { 32 | fmt.Fprintln(os.Stderr, err.Error()) 33 | os.Exit(1) 34 | } 35 | client := dune.NewDuneClient(env) 36 | var execution dune.Execution 37 | 38 | var queryParameters map[string]any 39 | err = json.Unmarshal([]byte(*queryParametersStr), &queryParameters) 40 | if err != nil { 41 | fmt.Fprintf(os.Stderr, "Failed to parse query parameters: %s\n", err.Error()) 42 | } 43 | 44 | if *executionID == "" { 45 | // Submitting query for new execution 46 | execution, err = client.RunQuery(*queryID, queryParameters) 47 | if err != nil { 48 | fmt.Fprintln(os.Stderr, "failed to run query:", err) 49 | os.Exit(1) 50 | } 51 | } else { 52 | // Using existing execution ID 53 | execution = dune.NewExecution(client, *executionID) 54 | } 55 | 56 | // An Execution object provides an interface for getting its state, results, 57 | // cancelling it, or blocking until it completes, which is what WaitGetResults does 58 | result, err := execution.WaitGetResults(*pollInterval, *maxRetries) 59 | if err != nil { 60 | fmt.Fprintln(os.Stderr, "failed to retrieve results:", err) 61 | } 62 | 63 | out, err := json.Marshal(result) 64 | if err != nil { 65 | fmt.Fprintln(os.Stderr, "failed to encode result as json:", err) 66 | } 67 | 68 | fmt.Println(string(out)) 69 | } 70 | -------------------------------------------------------------------------------- /models/uploads.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type UploadsColumn struct { 8 | Name string `json:"name"` 9 | Type string `json:"type"` 10 | Nullable bool `json:"nullable"` 11 | Metadata map[string]any `json:"metadata,omitempty"` 12 | Description string `json:"description,omitempty"` 13 | } 14 | 15 | type UploadsOwner struct { 16 | Handle string `json:"handle"` 17 | Type string `json:"type"` 18 | } 19 | 20 | type UploadsListElement struct { 21 | FullName string `json:"full_name"` 22 | IsPrivate bool `json:"is_private"` 23 | TableSizeBytes string `json:"table_size_bytes"` 24 | CreatedAt time.Time `json:"created_at"` 25 | UpdatedAt time.Time `json:"updated_at"` 26 | PurgedAt *time.Time `json:"purged_at,omitempty"` 27 | Owner UploadsOwner `json:"owner"` 28 | Columns []UploadsColumn `json:"columns"` 29 | } 30 | 31 | type UploadsListResponse struct { 32 | Tables []UploadsListElement `json:"tables"` 33 | } 34 | 35 | type UploadsCreateRequest struct { 36 | Namespace string `json:"namespace"` 37 | TableName string `json:"table_name"` 38 | Schema []UploadsColumn `json:"schema"` 39 | Description string `json:"description,omitempty"` 40 | IsPrivate bool `json:"is_private,omitempty"` 41 | } 42 | 43 | type UploadsCreateResponse struct { 44 | Namespace string `json:"namespace"` 45 | TableName string `json:"table_name"` 46 | FullName string `json:"full_name"` 47 | ExampleQuery string `json:"example_query"` 48 | AlreadyExisted bool `json:"already_existed"` 49 | Message string `json:"message"` 50 | } 51 | 52 | type UploadsCSVRequest struct { 53 | TableName string `json:"table_name"` 54 | Data string `json:"data"` 55 | Description string `json:"description,omitempty"` 56 | IsPrivate bool `json:"is_private,omitempty"` 57 | } 58 | 59 | type UploadsCSVResponse struct { 60 | Success bool `json:"success"` 61 | TableName string `json:"table_name"` 62 | FullName string `json:"full_name"` 63 | ExampleQuery string `json:"example_query"` 64 | } 65 | 66 | type UploadsInsertResponse struct { 67 | Name string `json:"name"` 68 | RowsWritten int64 `json:"rows_written"` 69 | BytesWritten int64 `json:"bytes_written"` 70 | } 71 | 72 | type UploadsDeleteResponse struct { 73 | Message string `json:"message"` 74 | } 75 | 76 | type UploadsClearResponse struct { 77 | Message string `json:"message"` 78 | } 79 | -------------------------------------------------------------------------------- /dune/execution.go: -------------------------------------------------------------------------------- 1 | package dune 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "time" 8 | 9 | "github.com/duneanalytics/duneapi-client-go/models" 10 | ) 11 | 12 | type execution struct { 13 | client DuneClient 14 | ID string 15 | } 16 | 17 | type Execution interface { 18 | // QueryCancel cancels the execution 19 | Cancel() error 20 | // GetResults returns the results or status of the execution, depending on whether it has completed 21 | GetResults() (*models.ResultsResponse, error) 22 | // GetResultsCSV returns the results in CSV format 23 | GetResultsCSV() (io.Reader, error) 24 | // QueryStatus returns the current execution status 25 | GetStatus() (*models.StatusResponse, error) 26 | 27 | // GetResultsV2 returns the results or status of the execution, depending on whether it has completed 28 | // it uses options to refine futher what results to get 29 | GetResultsV2(options models.ResultOptions) (*models.ResultsResponse, error) 30 | 31 | // RunQueryGetResults blocks until the execution is finished and returns the result 32 | // maxRetries is used when using the RunQueryToCompletion method, to limit the number of times the method 33 | // will tolerate API errors before giving up. A value of zero will disable the retry limit. 34 | // It is recommended to set this to something non-zero, as there is a risk that this will block indefinitely 35 | // if the Dune API is unreachable or returns an error. The pollInterval determines how long to wait between 36 | // GetResult requests. It is recommended to set to at least 5 seconds to prevent rate-limiting. 37 | WaitGetResults(pollInterval time.Duration, maxRetries int) (*models.ResultsResponse, error) 38 | // GetID returns the execution ID 39 | GetID() string 40 | } 41 | 42 | // NewExecution is used to instantiate a new execution object given an Dune client object 43 | // and existing execution ID. It is used to run further interactions with the execution, e.g. 44 | // retrieve its status, get results, cancel, etc. 45 | func NewExecution(client DuneClient, ID string) *execution { 46 | return &execution{ 47 | client: client, 48 | ID: ID, 49 | } 50 | } 51 | 52 | func (e *execution) Cancel() error { 53 | return e.client.QueryCancel(e.ID) 54 | } 55 | 56 | func (e *execution) GetStatus() (*models.StatusResponse, error) { 57 | return e.client.QueryStatus(e.ID) 58 | } 59 | 60 | func (e *execution) GetResults() (*models.ResultsResponse, error) { 61 | return e.client.QueryResults(e.ID) 62 | } 63 | 64 | func (e *execution) GetResultsV2(opts models.ResultOptions) (*models.ResultsResponse, error) { 65 | return e.client.QueryResultsV2(e.ID, opts) 66 | } 67 | 68 | func (e *execution) GetResultsCSV() (io.Reader, error) { 69 | return e.client.QueryResultsCSV(e.ID) 70 | } 71 | 72 | func (e *execution) WaitGetResults(pollInterval time.Duration, maxRetries int) (*models.ResultsResponse, error) { 73 | errCount := 0 74 | for { 75 | resultsResp, err := e.client.QueryResultsV2(e.ID, models.ResultOptions{}) 76 | if err != nil { 77 | if maxRetries != 0 && errCount > maxRetries { 78 | return nil, fmt.Errorf("%w. %s", ErrorRetriesExhausted, err.Error()) 79 | } 80 | fmt.Fprintln(os.Stderr, "failed to retrieve results. Retrying...\n", err) 81 | errCount += 1 82 | } else if resultsResp.IsExecutionFinished { 83 | return resultsResp, nil 84 | } 85 | time.Sleep(pollInterval) 86 | } 87 | } 88 | 89 | func (e *execution) GetID() string { 90 | return e.ID 91 | } 92 | -------------------------------------------------------------------------------- /e2e/datasets_test.go: -------------------------------------------------------------------------------- 1 | package e2e 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | // TestDatasetsIntegration contains E2E tests for Datasets API endpoints. 11 | // These tests require DUNE_API_KEY and DUNE_API_KEY_OWNER_HANDLE environment variables. 12 | // Run with: DUNE_API_KEY=key DUNE_API_KEY_OWNER_HANDLE=namespace go test ./e2e/... 13 | func TestListDatasets(t *testing.T) { 14 | if testing.Short() { 15 | t.Skip("Skipping E2E test in short mode") 16 | } 17 | 18 | client := setupClient(t) 19 | 20 | result, err := client.ListDatasets(10, 0, "", "uploaded_table") 21 | require.NoError(t, err) 22 | assert.NotNil(t, result.Datasets) 23 | assert.GreaterOrEqual(t, result.Total, 0) 24 | 25 | if len(result.Datasets) > 0 { 26 | dataset := result.Datasets[0] 27 | assert.NotEmpty(t, dataset.FullName) 28 | assert.NotEmpty(t, dataset.Type) 29 | assert.NotNil(t, dataset.Owner) 30 | assert.NotNil(t, dataset.Columns) 31 | } 32 | } 33 | 34 | func TestListDatasetsWithFilters(t *testing.T) { 35 | if testing.Short() { 36 | t.Skip("Skipping E2E test in short mode") 37 | } 38 | 39 | client := setupClient(t) 40 | 41 | result, err := client.ListDatasets(5, 0, "", "transformation_view") 42 | require.NoError(t, err) 43 | assert.NotNil(t, result.Datasets) 44 | 45 | for _, dataset := range result.Datasets { 46 | assert.Equal(t, "transformation_view", dataset.Type) 47 | } 48 | } 49 | 50 | func TestListDatasetsByOwner(t *testing.T) { 51 | if testing.Short() { 52 | t.Skip("Skipping E2E test in short mode") 53 | } 54 | 55 | client := setupClient(t) 56 | 57 | result, err := client.ListDatasets(5, 0, "dune", "") 58 | require.NoError(t, err) 59 | assert.NotNil(t, result.Datasets) 60 | 61 | for _, dataset := range result.Datasets { 62 | if dataset.Owner != nil { 63 | assert.Equal(t, "dune", dataset.Owner.Handle) 64 | } 65 | } 66 | } 67 | 68 | func TestGetDataset(t *testing.T) { 69 | if testing.Short() { 70 | t.Skip("Skipping E2E test in short mode") 71 | } 72 | 73 | client := setupClient(t) 74 | 75 | listResult, err := client.ListDatasets(1, 0, "", "uploaded_table") 76 | require.NoError(t, err) 77 | 78 | if len(listResult.Datasets) == 0 { 79 | t.Skip("No uploaded tables found to test") 80 | } 81 | 82 | fullName := listResult.Datasets[0].FullName 83 | result, err := client.GetDataset(fullName) 84 | require.NoError(t, err) 85 | 86 | assert.Equal(t, fullName, result.FullName) 87 | assert.NotEmpty(t, result.Type) 88 | assert.NotNil(t, result.Owner) 89 | assert.NotNil(t, result.Columns) 90 | assert.Greater(t, len(result.Columns), 0) 91 | 92 | column := result.Columns[0] 93 | assert.NotEmpty(t, column.Name) 94 | assert.NotEmpty(t, column.Type) 95 | } 96 | 97 | func TestGetDatasetWithUploadedTable(t *testing.T) { 98 | if testing.Short() { 99 | t.Skip("Skipping E2E test in short mode") 100 | } 101 | 102 | client := setupClient(t) 103 | 104 | listResult, err := client.ListDatasets(1, 0, "", "uploaded_table") 105 | require.NoError(t, err) 106 | 107 | if len(listResult.Datasets) > 0 { 108 | fullName := listResult.Datasets[0].FullName 109 | result, err := client.GetDataset(fullName) 110 | require.NoError(t, err) 111 | 112 | assert.Equal(t, fullName, result.FullName) 113 | assert.Equal(t, "uploaded_table", result.Type) 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /models/results.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/url" 7 | "slices" 8 | "strings" 9 | "time" 10 | ) 11 | 12 | const LimitRows = 32_000 13 | 14 | type ResultMetadata struct { 15 | ColumnNames []string `json:"column_names,omitempty"` 16 | ResultSetBytes int64 `json:"result_set_bytes,omitempty"` 17 | RowCount int `json:"row_count,omitempty"` 18 | TotalResultSetBytes int64 `json:"total_result_set_bytes,omitempty"` 19 | TotalRowCount int `json:"total_row_count,omitempty"` 20 | DatapointCount int `json:"datapoint_count,omitempty"` 21 | } 22 | 23 | type Result struct { 24 | Metadata ResultMetadata `json:"metadata,omitempty"` 25 | Rows []map[string]any `json:"rows,omitempty"` 26 | } 27 | 28 | type ResultsResponse struct { 29 | QueryID int64 `json:"query_id"` 30 | State string `json:"state"` 31 | SubmittedAt time.Time `json:"submitted_at"` 32 | ExpiresAt time.Time `json:"expires_at"` 33 | ExecutionStartedAt *time.Time `json:"execution_started_at,omitempty"` 34 | ExecutionEndedAt *time.Time `json:"execution_ended_at,omitempty"` 35 | CancelledAt *time.Time `json:"cancelled_at,omitempty"` 36 | Error *ExecutionError `json:"error,omitempty"` 37 | Result Result `json:"result,omitempty"` 38 | NextOffset *uint64 `json:"next_offset,omitempty"` 39 | NextURI *string `json:"next_uri,omitempty"` 40 | IsExecutionFinished bool `json:"is_execution_finished,omitempty"` 41 | } 42 | 43 | func (r ResultsResponse) HasError() error { 44 | if !strings.HasPrefix(r.State, "QUERY_STATE_") { 45 | return fmt.Errorf("bad state: %v", r.State) 46 | } 47 | 48 | if r.State == "QUERY_STATE_COMPLETED" { 49 | if r.ExecutionEndedAt == nil { 50 | return errors.New("missing execution endedAt") 51 | } 52 | if len(r.Result.Rows) != r.Result.Metadata.RowCount { 53 | return fmt.Errorf("missmatch row count: len(rows): %v, TotalRowCount: %v", 54 | len(r.Result.Rows), 55 | r.Result.Metadata.TotalRowCount, 56 | ) 57 | } 58 | } else { 59 | if r.Result.Rows != nil { 60 | return fmt.Errorf("cannot have result if state: %v", r.State) 61 | } 62 | } 63 | 64 | if r.State == "QUERY_STATE_CANCELLED" { 65 | if r.CancelledAt == nil { 66 | return errors.New("missing cancelled at") 67 | } 68 | } else { 69 | if r.CancelledAt != nil { 70 | return errors.New("field CancelledAt shouldn't be present") 71 | } 72 | } 73 | return nil 74 | } 75 | 76 | func (r ResultsResponse) IsEmpty() bool { 77 | return r.State == "" && r.QueryID == 0 && r.SubmittedAt.Equal(time.Time{}) 78 | } 79 | 80 | func (r *ResultsResponse) AddPageResult(pageResp *ResultsResponse) { 81 | if r.IsEmpty() { 82 | // empty result, copy the first page 83 | r.QueryID = pageResp.QueryID 84 | r.State = pageResp.State 85 | r.SubmittedAt = pageResp.SubmittedAt 86 | r.ExpiresAt = pageResp.ExpiresAt 87 | r.ExecutionStartedAt = pageResp.ExecutionStartedAt 88 | r.ExecutionEndedAt = pageResp.ExecutionEndedAt 89 | r.CancelledAt = pageResp.CancelledAt 90 | r.Error = pageResp.Error 91 | r.NextOffset = pageResp.NextOffset 92 | r.NextURI = pageResp.NextURI 93 | r.IsExecutionFinished = pageResp.IsExecutionFinished 94 | // re-use full result from first page 95 | r.Result = pageResp.Result 96 | } else { 97 | // append rows and the incremental metadata fields 98 | r.Result.Rows = slices.Concat(r.Result.Rows, pageResp.Result.Rows) 99 | r.Result.Metadata.ResultSetBytes += pageResp.Result.Metadata.ResultSetBytes 100 | r.Result.Metadata.RowCount += pageResp.Result.Metadata.RowCount 101 | r.Result.Metadata.DatapointCount += pageResp.Result.Metadata.DatapointCount 102 | r.IsExecutionFinished = pageResp.IsExecutionFinished 103 | r.NextOffset = pageResp.NextOffset 104 | } 105 | } 106 | 107 | // ResultOptions is a struct that contains options for getting a result 108 | type ResultOptions struct { 109 | // request a specific page of rows 110 | Page *ResultPageOption 111 | } 112 | 113 | func (r ResultOptions) ToURLValues() url.Values { 114 | v := url.Values{} 115 | if r.Page != nil { 116 | if r.Page.Offset > 0 { 117 | v.Add("offset", fmt.Sprintf("%d", r.Page.Offset)) 118 | } 119 | limit := r.Page.Limit 120 | if limit == 0 { 121 | limit = LimitRows 122 | } 123 | v.Add("limit", fmt.Sprintf("%d", limit)) 124 | } else { 125 | // always paginate the requests 126 | v.Add("limit", fmt.Sprintf("%d", LimitRows)) 127 | } 128 | 129 | return v 130 | } 131 | 132 | // To paginate a large result set 133 | type ResultPageOption struct { 134 | // we can have more than 2^32 rows, so we need to use int64 for the offset 135 | Offset uint64 136 | // assume server can't return more than 2^32 rows 137 | Limit uint32 138 | } 139 | -------------------------------------------------------------------------------- /e2e/uploads_test.go: -------------------------------------------------------------------------------- 1 | package e2e 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "testing" 7 | "time" 8 | 9 | "github.com/duneanalytics/duneapi-client-go/config" 10 | "github.com/duneanalytics/duneapi-client-go/dune" 11 | "github.com/duneanalytics/duneapi-client-go/models" 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/require" 14 | ) 15 | 16 | // TestUploadsIntegration contains E2E tests for Uploads API endpoints. 17 | // These tests require DUNE_API_KEY and DUNE_API_KEY_OWNER_HANDLE environment variables. 18 | // They also require a Plus subscription. 19 | // Run with: DUNE_API_KEY=key DUNE_API_KEY_OWNER_HANDLE=namespace go test ./e2e/... 20 | func TestCreateAndDeleteUpload(t *testing.T) { 21 | if testing.Short() { 22 | t.Skip("Skipping E2E test in short mode") 23 | } 24 | 25 | client := setupClient(t) 26 | namespace := getTestNamespace(t) 27 | tableName := generateTableName() 28 | 29 | schema := []models.UploadsColumn{ 30 | {Name: "id", Type: "integer", Nullable: false}, 31 | {Name: "name", Type: "varchar", Nullable: false}, 32 | {Name: "value", Type: "double", Nullable: true}, 33 | } 34 | 35 | createResp, err := client.CreateUpload(models.UploadsCreateRequest{ 36 | Namespace: namespace, 37 | TableName: tableName, 38 | Schema: schema, 39 | Description: "Test table created by E2E test", 40 | IsPrivate: true, 41 | }) 42 | require.NoError(t, err) 43 | assert.Equal(t, tableName, createResp.TableName) 44 | assert.Equal(t, namespace, createResp.Namespace) 45 | assert.NotEmpty(t, createResp.FullName) 46 | 47 | deleteResp, err := client.DeleteUpload(namespace, tableName) 48 | require.NoError(t, err) 49 | assert.NotEmpty(t, deleteResp.Message) 50 | } 51 | 52 | func TestUploadCSVAndDelete(t *testing.T) { 53 | if testing.Short() { 54 | t.Skip("Skipping E2E test in short mode") 55 | } 56 | 57 | client := setupClient(t) 58 | namespace := getTestNamespace(t) 59 | tableName := generateTableName() 60 | 61 | csvData := `id,name,value 62 | 1,Alice,10.5 63 | 2,Bob,20.3 64 | 3,Charlie,15.7` 65 | 66 | csvResp, err := client.UploadCSV(models.UploadsCSVRequest{ 67 | TableName: tableName, 68 | Data: csvData, 69 | Description: "CSV uploaded by E2E test", 70 | IsPrivate: true, 71 | }) 72 | require.NoError(t, err) 73 | assert.Equal(t, tableName, csvResp.TableName) 74 | // FullName might be empty depending on API response 75 | if csvResp.FullName != "" { 76 | assert.NotEmpty(t, csvResp.FullName) 77 | } 78 | 79 | deleteResp, err := client.DeleteUpload(namespace, fmt.Sprintf("dataset_%s", tableName)) 80 | require.NoError(t, err) 81 | assert.NotEmpty(t, deleteResp.Message) 82 | } 83 | 84 | func TestListUploads(t *testing.T) { 85 | if testing.Short() { 86 | t.Skip("Skipping E2E test in short mode") 87 | } 88 | 89 | client := setupClient(t) 90 | 91 | listResp, err := client.ListUploads(10, 0) 92 | require.NoError(t, err) 93 | assert.NotNil(t, listResp.Tables) 94 | } 95 | 96 | func TestFullUploadLifecycle(t *testing.T) { 97 | if testing.Short() { 98 | t.Skip("Skipping E2E test in short mode") 99 | } 100 | 101 | client := setupClient(t) 102 | namespace := getTestNamespace(t) 103 | tableName := generateTableName() 104 | 105 | schema := []models.UploadsColumn{ 106 | {Name: "id", Type: "integer", Nullable: false}, 107 | {Name: "message", Type: "varchar", Nullable: false}, 108 | } 109 | 110 | createResp, err := client.CreateUpload(models.UploadsCreateRequest{ 111 | Namespace: namespace, 112 | TableName: tableName, 113 | Schema: schema, 114 | Description: "Full lifecycle test", 115 | IsPrivate: true, 116 | }) 117 | require.NoError(t, err) 118 | assert.Equal(t, tableName, createResp.TableName) 119 | 120 | csvData := "id,message\n1,Hello\n2,World\n" 121 | insertResp, err := client.InsertIntoUpload(namespace, tableName, csvData, "text/csv") 122 | require.NoError(t, err) 123 | assert.Equal(t, int64(2), insertResp.RowsWritten) 124 | 125 | clearResp, err := client.ClearUpload(namespace, tableName) 126 | require.NoError(t, err) 127 | assert.NotEmpty(t, clearResp.Message) 128 | 129 | deleteResp, err := client.DeleteUpload(namespace, tableName) 130 | require.NoError(t, err) 131 | assert.NotEmpty(t, deleteResp.Message) 132 | } 133 | 134 | func TestInsertNDJSON(t *testing.T) { 135 | if testing.Short() { 136 | t.Skip("Skipping E2E test in short mode") 137 | } 138 | 139 | client := setupClient(t) 140 | namespace := getTestNamespace(t) 141 | tableName := generateTableName() 142 | 143 | schema := []models.UploadsColumn{ 144 | {Name: "id", Type: "integer", Nullable: false}, 145 | {Name: "data", Type: "varchar", Nullable: true}, 146 | } 147 | 148 | _, err := client.CreateUpload(models.UploadsCreateRequest{ 149 | Namespace: namespace, 150 | TableName: tableName, 151 | Schema: schema, 152 | Description: "NDJSON test", 153 | IsPrivate: true, 154 | }) 155 | require.NoError(t, err) 156 | 157 | ndjsonData := `{"id":1,"data":"test1"} 158 | {"id":2,"data":"test2"}` 159 | 160 | insertResp, err := client.InsertIntoUpload(namespace, tableName, ndjsonData, "application/x-ndjson") 161 | require.NoError(t, err) 162 | assert.Equal(t, int64(2), insertResp.RowsWritten) 163 | 164 | _, err = client.DeleteUpload(namespace, tableName) 165 | require.NoError(t, err) 166 | } 167 | 168 | func setupClient(t *testing.T) dune.DuneClient { 169 | apiKey := os.Getenv("DUNE_API_KEY") 170 | if apiKey == "" { 171 | t.Fatal("DUNE_API_KEY environment variable must be set to run E2E tests") 172 | } 173 | 174 | env := config.FromAPIKey(apiKey) 175 | return dune.NewDuneClient(env) 176 | } 177 | 178 | func getTestNamespace(t *testing.T) string { 179 | namespace := os.Getenv("DUNE_API_KEY_OWNER_HANDLE") 180 | if namespace == "" { 181 | t.Fatal("DUNE_API_KEY_OWNER_HANDLE environment variable must be set to run E2E tests") 182 | } 183 | return namespace 184 | } 185 | 186 | func generateTableName() string { 187 | return fmt.Sprintf("test_uploads_api_%d", time.Now().Unix()) 188 | } 189 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DuneAPI client 2 | DuneAPI CLI and client library for Go 3 | 4 | ## Library usage 5 | 6 | To add this library to your go project run: 7 | 8 | ``` 9 | go get github.com/duneanalytics/duneapi-client-go 10 | ``` 11 | 12 | First you have to define the configuration that will be used to authenticate 13 | with the Dune API. There are three ways to achieve this. 14 | 15 | 16 | ```go 17 | import ( 18 | "github.com/duneanalytics/duneapi-client-go/config" 19 | "github.com/duneanalytics/duneapi-client-go/dune" 20 | ) 21 | 22 | func main() { 23 | // Use one of the following options 24 | // Read config from DUNE_API_KEY and DUNE_API_HOST environment variables 25 | env, err := config.FromEnvVars() 26 | if err != nil { 27 | // handle error 28 | } 29 | 30 | // Define it from your code 31 | env = config.FromAPIKey("Your_API_Key") 32 | 33 | // Define manually 34 | env = &config.Env{ 35 | APIKey: "Your_API_Key", 36 | // you can define a different domain to connect to, for example for a mocked API 37 | Host: "https://api.example.com", 38 | } 39 | 40 | // Next, instantiate and use a Dune client object 41 | client := dune.NewDuneClient(env) 42 | queryID := 1234 43 | queryParameters := map[string]any{ 44 | "paramKey": "paramValue", 45 | } 46 | rows, err := client.RunQueryGetRows(queryID, queryParameters) 47 | if err != nil { 48 | // handle error 49 | } 50 | 51 | for row := range rows { 52 | // ... 53 | } 54 | } 55 | ``` 56 | 57 | The RunQueryGetRows will execute the query, wait for completion and return 58 | only an array of rows, without any metadata. For other ways to use the client, 59 | check out the [package documentation](https://pkg.go.dev/github.com/duneanalytics/duneapi-client-go). 60 | 61 | ### Dataset Discovery APIs 62 | 63 | The client provides methods to discover and explore datasets available on Dune: 64 | 65 | ```go 66 | // List all datasets with optional filtering 67 | datasets, err := client.ListDatasets( 68 | 50, // limit 69 | 0, // offset 70 | "dune", // owner_handle (optional, use "" to skip) 71 | "spell", // dataset_type (optional, use "" to skip) 72 | ) 73 | if err != nil { 74 | // handle error 75 | } 76 | 77 | for _, dataset := range datasets.Datasets { 78 | fmt.Printf("Dataset: %s (%s.%s)\n", dataset.Slug, dataset.Namespace, dataset.TableName) 79 | fmt.Printf(" Owner: %s\n", dataset.Owner.Handle) 80 | fmt.Printf(" Columns: %d\n", len(dataset.Columns)) 81 | } 82 | 83 | // Get detailed information about a specific dataset 84 | dataset, err := client.GetDataset("dex.trades") 85 | if err != nil { 86 | // handle error 87 | } 88 | 89 | fmt.Printf("Dataset: %s\n", dataset.Name) 90 | fmt.Printf("Description: %s\n", dataset.Description) 91 | for _, col := range dataset.Columns { 92 | fmt.Printf(" - %s (%s, nullable: %v)\n", col.Name, col.Type, col.Nullable) 93 | } 94 | ``` 95 | 96 | ### Table Management APIs 97 | 98 | The client provides comprehensive methods for managing uploaded tables: 99 | 100 | ```go 101 | // List all uploaded tables 102 | tables, err := client.ListUploads(50, 0) 103 | if err != nil { 104 | // handle error 105 | } 106 | 107 | for _, table := range tables.Tables { 108 | fmt.Printf("Table: %s (size: %s bytes)\n", table.FullName, table.TableSizeBytes) 109 | } 110 | 111 | // Create a new table with defined schema 112 | createResp, err := client.CreateUpload(models.UploadsCreateRequest{ 113 | Namespace: "my_user", 114 | TableName: "interest_rates", 115 | Description: "10 year daily interest rates", 116 | IsPrivate: false, 117 | Schema: []models.UploadsColumn{ 118 | { 119 | Name: "date", 120 | Type: "timestamp", 121 | Nullable: false, 122 | }, 123 | { 124 | Name: "rate", 125 | Type: "double", 126 | Nullable: true, 127 | }, 128 | }, 129 | }) 130 | if err != nil { 131 | // handle error 132 | } 133 | fmt.Printf("Created table: %s\n", createResp.FullName) 134 | 135 | // Upload CSV data to create a new table 136 | csvResp, err := client.UploadCSV(models.UploadsCSVRequest{ 137 | TableName: "my_table", 138 | Data: "col1,col2\nval1,val2\nval3,val4", 139 | Description: "My test table", 140 | IsPrivate: false, 141 | }) 142 | if err != nil { 143 | // handle error 144 | } 145 | fmt.Printf("Uploaded CSV to: %s\n", csvResp.FullName) 146 | 147 | // Insert data into an existing table (CSV format) 148 | insertResp, err := client.InsertIntoUpload( 149 | "my_user", 150 | "interest_rates", 151 | "2024-01-01,3.5\n2024-01-02,3.6", 152 | "text/csv", 153 | ) 154 | if err != nil { 155 | // handle error 156 | } 157 | fmt.Printf("Inserted %d rows (%d bytes)\n", insertResp.RowsWritten, insertResp.BytesWritten) 158 | 159 | // Insert data in NDJSON format 160 | ndjsonData := `{"date":"2024-01-03","rate":3.7} 161 | {"date":"2024-01-04","rate":3.8}` 162 | insertResp, err = client.InsertIntoUpload( 163 | "my_user", 164 | "interest_rates", 165 | ndjsonData, 166 | "application/x-ndjson", 167 | ) 168 | 169 | // Clear all data from a table (preserves schema) 170 | clearResp, err := client.ClearUpload("my_user", "interest_rates") 171 | if err != nil { 172 | // handle error 173 | } 174 | 175 | // Delete a table permanently 176 | deleteResp, err := client.DeleteUpload("my_user", "interest_rates") 177 | if err != nil { 178 | // handle error 179 | } 180 | ``` 181 | 182 | #### Deprecated Table Endpoints 183 | 184 | ⚠️ **DEPRECATION NOTICE**: The following methods use deprecated `/v1/table/*` endpoints and will be removed on **March 1, 2026**. Please migrate to the new methods shown above. 185 | 186 | ```go 187 | // DEPRECATED: Use ListUploads instead 188 | tables, err := client.ListTables(50, 0) 189 | 190 | // DEPRECATED: Use CreateUpload instead 191 | createResp, err := client.CreateTable(req) 192 | 193 | // DEPRECATED: Use UploadCSV instead 194 | csvResp, err := client.UploadCSVDeprecated(req) 195 | 196 | // DEPRECATED: Use DeleteUpload instead 197 | deleteResp, err := client.DeleteTable("my_user", "table_name") 198 | 199 | // DEPRECATED: Use ClearUpload instead 200 | clearResp, err := client.ClearTable("my_user", "table_name") 201 | 202 | // DEPRECATED: Use InsertIntoUpload instead 203 | insertResp, err := client.InsertTable("my_user", "table_name", data, contentType) 204 | ``` 205 | 206 | ## CLI usage 207 | 208 | ### Build 209 | 210 | ``` 211 | go build -o dunecli cmd/main.go 212 | ``` 213 | 214 | You can use it from the repo directly or copy to a directory in your `$PATH` 215 | 216 | ### Usage 217 | 218 | The CLI has 2 main modes of operation. Run a query or retrieve information about 219 | an existing execution. In both cases, it will print out raw minified JSON to stdout, 220 | so if you want to prettify it, or select a specific key, you can pipe to [jq](https://stedolan.github.io/jq/). 221 | 222 | #### Execute a query 223 | 224 | To trigger a query execution and print the results once it's done: 225 | 226 | ```bash 227 | DUNE_API_KEY= ./dunecli -q 228 | ``` 229 | 230 | If the query has parameters you want to override, use: 231 | 232 | ```bash 233 | DUNE_API_KEY= ./dunecli -q -p '{"": ""}' 234 | ``` 235 | 236 | For numeric parameters, omit the quotes around the value. 237 | 238 | #### Get results for an existing execution 239 | 240 | If you already have an execution ID, you can retrieve its results (or state if it 241 | hasn't completed yet) with this: 242 | 243 | ```bash 244 | DUNE_API_KEY= ./dunecli -e 245 | ``` 246 | -------------------------------------------------------------------------------- /dune/dune.go: -------------------------------------------------------------------------------- 1 | package dune 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "net/url" 11 | "time" 12 | 13 | "github.com/duneanalytics/duneapi-client-go/config" 14 | "github.com/duneanalytics/duneapi-client-go/models" 15 | ) 16 | 17 | // DuneClient represents all operations available to call externally 18 | type DuneClient interface { 19 | // New APIs to read results in a more flexible way 20 | // returns the results or status of an execution, depending on whether it has completed 21 | QueryResultsV2(executionID string, options models.ResultOptions) (*models.ResultsResponse, error) 22 | // returns the results of a QueryID, depending on whether it has completed 23 | ResultsByQueryID(queryID string, options models.ResultOptions) (*models.ResultsResponse, error) 24 | 25 | // RunQueryGetRows submits a query for execution and returns an Execution object 26 | RunQuery(queryID int, queryParameters map[string]any) (Execution, error) 27 | // RunQueryGetRows submits a query for execution, blocks until execution is finished, and returns just the result rows 28 | RunQueryGetRows(queryID int, queryParameters map[string]any) ([]map[string]any, error) 29 | 30 | // QueryCancel cancels the execution of an execution in the pending or executing state 31 | QueryCancel(executionID string) error 32 | 33 | // QueryExecute submits a query to execute with the provided parameters 34 | QueryExecute(queryID int, queryParameters map[string]any) (*models.ExecuteResponse, error) 35 | 36 | // SQLExecute executes raw SQL with optional performance parameter 37 | SQLExecute(sql string, performance string) (*models.ExecuteResponse, error) 38 | 39 | // QueryPipelineExecute submits a query pipeline for execution with optional performance parameter 40 | QueryPipelineExecute(queryID string, performance string) (*models.PipelineExecuteResponse, error) 41 | 42 | // PipelineStatus returns the current pipeline execution status 43 | PipelineStatus(pipelineExecutionID string) (*models.PipelineStatusResponse, error) 44 | 45 | // RunSQL submits raw SQL for execution and returns an Execution object 46 | RunSQL(sql string, performance string) (Execution, error) 47 | 48 | // QueryStatus returns the current execution status 49 | QueryStatus(executionID string) (*models.StatusResponse, error) 50 | 51 | // QueryResults returns the results or status of an execution, depending on whether it has completed 52 | // DEPRECATED, use QueryResultsV2 instead 53 | QueryResults(executionID string) (*models.ResultsResponse, error) 54 | 55 | // QueryResultsCSV returns the results of an execution, as CSV text stream if the execution has completed 56 | QueryResultsCSV(executionID string) (io.Reader, error) 57 | 58 | // QueryResultsByQueryID returns the results of the lastest execution for a given query ID 59 | // DEPRECATED, use ResultsByQueryID instead 60 | QueryResultsByQueryID(queryID string) (*models.ResultsResponse, error) 61 | 62 | // QueryResultsCSVByQueryID returns the results of the lastest execution for a given query ID 63 | // as CSV text stream if the execution has completed 64 | QueryResultsCSVByQueryID(queryID string) (io.Reader, error) 65 | 66 | // GetUsage returns usage statistics for the current billing period 67 | GetUsage() (*models.UsageResponse, error) 68 | 69 | // GetUsageForDates returns usage statistics for a specified time range 70 | GetUsageForDates(startDate, endDate string) (*models.UsageResponse, error) 71 | 72 | // ListDatasets returns a paginated list of datasets with optional filtering 73 | ListDatasets(limit, offset int, ownerHandle, datasetType string) (*models.ListDatasetsResponse, error) 74 | 75 | // GetDataset returns detailed information about a specific dataset by slug 76 | GetDataset(slug string) (*models.DatasetResponse, error) 77 | 78 | // ListUploads returns a paginated list of uploaded tables 79 | ListUploads(limit, offset int) (*models.UploadsListResponse, error) 80 | 81 | // CreateUpload creates an empty table with defined schema 82 | CreateUpload(req models.UploadsCreateRequest) (*models.UploadsCreateResponse, error) 83 | 84 | // UploadCSV uploads CSV data to create a new table 85 | UploadCSV(req models.UploadsCSVRequest) (*models.UploadsCSVResponse, error) 86 | 87 | // DeleteUpload permanently deletes a table and all its data 88 | DeleteUpload(namespace, tableName string) (*models.UploadsDeleteResponse, error) 89 | 90 | // ClearUpload removes all data from a table while preserving schema 91 | ClearUpload(namespace, tableName string) (*models.UploadsClearResponse, error) 92 | 93 | // InsertIntoUpload inserts data into an existing table (CSV or NDJSON format) 94 | InsertIntoUpload(namespace, tableName, data, contentType string) (*models.UploadsInsertResponse, error) 95 | 96 | // DEPRECATED: Use ListUploads instead. Will be removed March 1, 2026. 97 | ListTables(limit, offset int) (*models.UploadsListResponse, error) 98 | 99 | // DEPRECATED: Use CreateUpload instead. Will be removed March 1, 2026. 100 | CreateTable(req models.UploadsCreateRequest) (*models.UploadsCreateResponse, error) 101 | 102 | // DEPRECATED: Use UploadCSV instead. Will be removed March 1, 2026. 103 | UploadCSVDeprecated(req models.UploadsCSVRequest) (*models.UploadsCSVResponse, error) 104 | 105 | // DEPRECATED: Use DeleteUpload instead. Will be removed March 1, 2026. 106 | DeleteTable(namespace, tableName string) (*models.UploadsDeleteResponse, error) 107 | 108 | // DEPRECATED: Use ClearUpload instead. Will be removed March 1, 2026. 109 | ClearTable(namespace, tableName string) (*models.UploadsClearResponse, error) 110 | 111 | // DEPRECATED: Use InsertIntoUpload instead. Will be removed March 1, 2026. 112 | InsertTable(namespace, tableName, data, contentType string) (*models.UploadsInsertResponse, error) 113 | } 114 | 115 | type duneClient struct { 116 | env *config.Env 117 | } 118 | 119 | var ( 120 | cancelURLTemplate = "%s/api/v1/execution/%s/cancel" 121 | executeURLTemplate = "%s/api/v1/query/%d/execute" 122 | sqlExecuteURLTemplate = "%s/api/v1/sql/execute" 123 | pipelineExecuteURLTemplate = "%s/api/v1/query/%s/pipeline/execute" 124 | pipelineStatusURLTemplate = "%s/api/v1/pipelines/executions/%s/status" 125 | statusURLTemplate = "%s/api/v1/execution/%s/status" 126 | executionResultsURLTemplate = "%s/api/v1/execution/%s/results" 127 | executionResultsCSVURLTemplate = "%s/api/v1/execution/%s/results/csv" 128 | queryResultsURLTemplate = "%s/api/v1/query/%s/results" 129 | queryResultsCSVURLTemplate = "%s/api/v1/query/%s/results/csv" 130 | usageURLTemplate = "%s/api/v1/usage" 131 | listDatasetsURLTemplate = "%s/api/v1/datasets" 132 | getDatasetURLTemplate = "%s/api/v1/datasets/%s" 133 | listUploadsURLTemplate = "%s/api/v1/uploads" 134 | createTableURLTemplate = "%s/api/v1/uploads" 135 | uploadCSVURLTemplate = "%s/api/v1/uploads/csv" 136 | deleteTableURLTemplate = "%s/api/v1/uploads/%s/%s" 137 | clearTableURLTemplate = "%s/api/v1/uploads/%s/%s/clear" 138 | insertTableURLTemplate = "%s/api/v1/uploads/%s/%s/insert" 139 | listTablesDeprecatedURLTemplate = "%s/api/v1/tables" 140 | createTableDeprecatedURLTemplate = "%s/api/v1/table/create" 141 | uploadCSVDeprecatedURLTemplate = "%s/api/v1/table/upload/csv" 142 | deleteTableDeprecatedURLTemplate = "%s/api/v1/table/%s/%s" 143 | clearTableDeprecatedURLTemplate = "%s/api/v1/table/%s/%s/clear" 144 | insertTableDeprecatedURLTemplate = "%s/api/v1/table/%s/%s/insert" 145 | ) 146 | 147 | var ErrorRetriesExhausted = errors.New("retries have been exhausted") 148 | 149 | // NewDuneClient instantiates a new stateless DuneAPI client. Env contains information about the 150 | // API key and target host (which shouldn't be changed, unless you want to run it through a custom proxy). 151 | func NewDuneClient(env *config.Env) *duneClient { 152 | return &duneClient{ 153 | env: env, 154 | } 155 | } 156 | 157 | func (c *duneClient) RunQuery(queryID int, queryParameters map[string]any) (Execution, error) { 158 | resp, err := c.QueryExecute(queryID, queryParameters) 159 | if err != nil { 160 | return nil, err 161 | } 162 | 163 | return &execution{ 164 | client: c, 165 | ID: resp.ExecutionID, 166 | }, nil 167 | } 168 | 169 | func (c *duneClient) RunSQL(sql string, performance string) (Execution, error) { 170 | resp, err := c.SQLExecute(sql, performance) 171 | if err != nil { 172 | return nil, err 173 | } 174 | 175 | return &execution{ 176 | client: c, 177 | ID: resp.ExecutionID, 178 | }, nil 179 | } 180 | 181 | func (c *duneClient) RunQueryGetRows(queryID int, queryParameters map[string]any) ([]map[string]any, error) { 182 | execution, err := c.RunQuery(queryID, queryParameters) 183 | if err != nil { 184 | return nil, err 185 | } 186 | 187 | pollInterval := 5 * time.Second 188 | maxRetries := 10 189 | resp, err := execution.WaitGetResults(pollInterval, maxRetries) 190 | if err != nil { 191 | return nil, err 192 | } 193 | 194 | return resp.Result.Rows, nil 195 | } 196 | 197 | func (c *duneClient) QueryCancel(executionID string) error { 198 | cancelURL := fmt.Sprintf(cancelURLTemplate, c.env.Host, executionID) 199 | req, err := http.NewRequest("POST", cancelURL, nil) 200 | if err != nil { 201 | return err 202 | } 203 | resp, err := httpRequest(c.env.APIKey, req) 204 | if err != nil { 205 | return err 206 | } 207 | 208 | var cancelResp models.CancelResponse 209 | decodeBody(resp, &cancelResp) 210 | if err := cancelResp.HasError(); err != nil { 211 | return err 212 | } 213 | 214 | return nil 215 | } 216 | 217 | func (c *duneClient) QueryExecute(queryID int, queryParameters map[string]any) (*models.ExecuteResponse, error) { 218 | executeURL := fmt.Sprintf(executeURLTemplate, c.env.Host, queryID) 219 | jsonData, err := json.Marshal(models.ExecuteRequest{ 220 | QueryParameters: queryParameters, 221 | }) 222 | if err != nil { 223 | return nil, err 224 | } 225 | req, err := http.NewRequest("POST", executeURL, bytes.NewBuffer(jsonData)) 226 | if err != nil { 227 | return nil, err 228 | } 229 | resp, err := httpRequest(c.env.APIKey, req) 230 | if err != nil { 231 | return nil, err 232 | } 233 | 234 | var executeResp models.ExecuteResponse 235 | decodeBody(resp, &executeResp) 236 | if err := executeResp.HasError(); err != nil { 237 | return nil, err 238 | } 239 | 240 | return &executeResp, nil 241 | } 242 | 243 | func (c *duneClient) SQLExecute(sql string, performance string) (*models.ExecuteResponse, error) { 244 | executeURL := fmt.Sprintf(sqlExecuteURLTemplate, c.env.Host) 245 | jsonData, err := json.Marshal(models.ExecuteSQLRequest{ 246 | SQL: sql, 247 | Performance: performance, 248 | }) 249 | if err != nil { 250 | return nil, err 251 | } 252 | 253 | req, err := http.NewRequest("POST", executeURL, bytes.NewBuffer(jsonData)) 254 | if err != nil { 255 | return nil, err 256 | } 257 | 258 | resp, err := httpRequest(c.env.APIKey, req) 259 | if err != nil { 260 | return nil, err 261 | } 262 | 263 | var executeResp models.ExecuteResponse 264 | decodeBody(resp, &executeResp) 265 | if err := executeResp.HasError(); err != nil { 266 | return nil, err 267 | } 268 | 269 | return &executeResp, nil 270 | } 271 | 272 | func (c *duneClient) QueryPipelineExecute(queryID string, performance string) (*models.PipelineExecuteResponse, error) { 273 | executeURL := fmt.Sprintf(pipelineExecuteURLTemplate, c.env.Host, queryID) 274 | jsonData, err := json.Marshal(models.PipelineExecuteRequest{ 275 | Performance: performance, 276 | }) 277 | if err != nil { 278 | return nil, err 279 | } 280 | 281 | req, err := http.NewRequest("POST", executeURL, bytes.NewBuffer(jsonData)) 282 | if err != nil { 283 | return nil, err 284 | } 285 | 286 | resp, err := httpRequest(c.env.APIKey, req) 287 | if err != nil { 288 | return nil, err 289 | } 290 | 291 | var pipelineResp models.PipelineExecuteResponse 292 | decodeBody(resp, &pipelineResp) 293 | 294 | return &pipelineResp, nil 295 | } 296 | 297 | func (c *duneClient) PipelineStatus(pipelineExecutionID string) (*models.PipelineStatusResponse, error) { 298 | statusURL := fmt.Sprintf(pipelineStatusURLTemplate, c.env.Host, pipelineExecutionID) 299 | req, err := http.NewRequest("GET", statusURL, nil) 300 | if err != nil { 301 | return nil, err 302 | } 303 | resp, err := httpRequest(c.env.APIKey, req) 304 | if err != nil { 305 | return nil, err 306 | } 307 | 308 | var pipelineStatusResp models.PipelineStatusResponse 309 | decodeBody(resp, &pipelineStatusResp) 310 | 311 | return &pipelineStatusResp, nil 312 | } 313 | 314 | func (c *duneClient) QueryStatus(executionID string) (*models.StatusResponse, error) { 315 | statusURL := fmt.Sprintf(statusURLTemplate, c.env.Host, executionID) 316 | req, err := http.NewRequest("GET", statusURL, nil) 317 | if err != nil { 318 | return nil, err 319 | } 320 | resp, err := httpRequest(c.env.APIKey, req) 321 | if err != nil { 322 | return nil, err 323 | } 324 | 325 | var statusResp models.StatusResponse 326 | decodeBody(resp, &statusResp) 327 | if err := statusResp.HasError(); err != nil { 328 | return nil, err 329 | } 330 | 331 | return &statusResp, nil 332 | } 333 | 334 | func (c *duneClient) getResults(url string, options models.ResultOptions) (*models.ResultsResponse, error) { 335 | var out models.ResultsResponse 336 | 337 | // track if we have request for a single page 338 | singlePage := options.Page != nil && (options.Page.Offset > 0 || options.Page.Limit > 0) 339 | 340 | if options.Page == nil { 341 | options.Page = &models.ResultPageOption{Limit: models.LimitRows} 342 | } 343 | 344 | for { 345 | url := fmt.Sprintf("%v?%v", url, options.ToURLValues().Encode()) 346 | req, err := http.NewRequest("GET", url, nil) 347 | if err != nil { 348 | return nil, err 349 | } 350 | resp, err := httpRequest(c.env.APIKey, req) 351 | if err != nil { 352 | return nil, err 353 | } 354 | 355 | var pageResp models.ResultsResponse 356 | decodeBody(resp, &pageResp) 357 | if err := pageResp.HasError(); err != nil { 358 | return nil, err 359 | } 360 | if singlePage { 361 | return &pageResp, nil 362 | } 363 | out.AddPageResult(&pageResp) 364 | 365 | if pageResp.NextOffset == nil { 366 | break 367 | } 368 | options.Page.Offset = *pageResp.NextOffset 369 | } 370 | 371 | return &out, nil 372 | } 373 | 374 | func (c *duneClient) getResultsCSV(url string) (io.Reader, error) { 375 | req, err := http.NewRequest("GET", url, nil) 376 | if err != nil { 377 | return nil, err 378 | } 379 | resp, err := httpRequest(c.env.APIKey, req) 380 | if err != nil { 381 | return nil, err 382 | } 383 | 384 | // we read whole result into ram here. if there was a paginated API we wouldn't need to 385 | var buf bytes.Buffer 386 | defer resp.Body.Close() 387 | _, err = buf.ReadFrom(resp.Body) 388 | return &buf, err 389 | } 390 | 391 | func (c *duneClient) QueryResultsV2(executionID string, options models.ResultOptions) (*models.ResultsResponse, error) { 392 | url := fmt.Sprintf(executionResultsURLTemplate, c.env.Host, executionID) 393 | return c.getResults(url, options) 394 | } 395 | 396 | func (c *duneClient) ResultsByQueryID(queryID string, options models.ResultOptions) (*models.ResultsResponse, error) { 397 | url := fmt.Sprintf(queryResultsURLTemplate, c.env.Host, queryID) 398 | return c.getResults(url, options) 399 | } 400 | 401 | func (c *duneClient) QueryResults(executionID string) (*models.ResultsResponse, error) { 402 | return c.QueryResultsV2(executionID, models.ResultOptions{}) 403 | } 404 | 405 | func (c *duneClient) QueryResultsByQueryID(queryID string) (*models.ResultsResponse, error) { 406 | return c.ResultsByQueryID(queryID, models.ResultOptions{}) 407 | } 408 | 409 | func (c *duneClient) QueryResultsCSV(executionID string) (io.Reader, error) { 410 | url := fmt.Sprintf(executionResultsCSVURLTemplate, c.env.Host, executionID) 411 | return c.getResultsCSV(url) 412 | } 413 | 414 | func (c *duneClient) QueryResultsCSVByQueryID(queryID string) (io.Reader, error) { 415 | url := fmt.Sprintf(queryResultsCSVURLTemplate, c.env.Host, queryID) 416 | return c.getResultsCSV(url) 417 | } 418 | 419 | func (c *duneClient) GetUsage() (*models.UsageResponse, error) { 420 | return c.getUsage(nil, nil) 421 | } 422 | 423 | func (c *duneClient) GetUsageForDates(startDate, endDate string) (*models.UsageResponse, error) { 424 | return c.getUsage(&startDate, &endDate) 425 | } 426 | 427 | func (c *duneClient) getUsage(startDate, endDate *string) (*models.UsageResponse, error) { 428 | usageURL := fmt.Sprintf(usageURLTemplate, c.env.Host) 429 | 430 | jsonData, err := json.Marshal(models.UsageRequest{ 431 | StartDate: startDate, 432 | EndDate: endDate, 433 | }) 434 | if err != nil { 435 | return nil, err 436 | } 437 | 438 | req, err := http.NewRequest("POST", usageURL, bytes.NewBuffer(jsonData)) 439 | if err != nil { 440 | return nil, err 441 | } 442 | 443 | resp, err := httpRequest(c.env.APIKey, req) 444 | if err != nil { 445 | return nil, err 446 | } 447 | 448 | var usageResp models.UsageResponse 449 | decodeBody(resp, &usageResp) 450 | 451 | return &usageResp, nil 452 | } 453 | 454 | func (c *duneClient) ListDatasets( 455 | limit, offset int, ownerHandle, datasetType string, 456 | ) (*models.ListDatasetsResponse, error) { 457 | listURL := fmt.Sprintf(listDatasetsURLTemplate, c.env.Host) 458 | 459 | params := fmt.Sprintf("?limit=%d&offset=%d", limit, offset) 460 | if ownerHandle != "" { 461 | params += fmt.Sprintf("&owner_handle=%s", url.QueryEscape(ownerHandle)) 462 | } 463 | if datasetType != "" { 464 | params += fmt.Sprintf("&type=%s", url.QueryEscape(datasetType)) 465 | } 466 | 467 | req, err := http.NewRequest("GET", listURL+params, nil) 468 | if err != nil { 469 | return nil, err 470 | } 471 | 472 | resp, err := httpRequest(c.env.APIKey, req) 473 | if err != nil { 474 | return nil, err 475 | } 476 | 477 | var datasetsResp models.ListDatasetsResponse 478 | decodeBody(resp, &datasetsResp) 479 | if err := datasetsResp.HasError(); err != nil { 480 | return nil, err 481 | } 482 | 483 | return &datasetsResp, nil 484 | } 485 | 486 | func (c *duneClient) GetDataset(slug string) (*models.DatasetResponse, error) { 487 | getURL := fmt.Sprintf(getDatasetURLTemplate, c.env.Host, slug) 488 | 489 | req, err := http.NewRequest("GET", getURL, nil) 490 | if err != nil { 491 | return nil, err 492 | } 493 | 494 | resp, err := httpRequest(c.env.APIKey, req) 495 | if err != nil { 496 | return nil, err 497 | } 498 | 499 | var datasetResp models.DatasetResponse 500 | decodeBody(resp, &datasetResp) 501 | if err := datasetResp.HasError(); err != nil { 502 | return nil, err 503 | } 504 | 505 | return &datasetResp, nil 506 | } 507 | 508 | func (c *duneClient) ListUploads(limit, offset int) (*models.UploadsListResponse, error) { 509 | listURL := fmt.Sprintf(listUploadsURLTemplate, c.env.Host) 510 | 511 | params := fmt.Sprintf("?limit=%d&offset=%d", limit, offset) 512 | 513 | req, err := http.NewRequest("GET", listURL+params, nil) 514 | if err != nil { 515 | return nil, err 516 | } 517 | 518 | resp, err := httpRequest(c.env.APIKey, req) 519 | if err != nil { 520 | return nil, err 521 | } 522 | 523 | var uploadsResp models.UploadsListResponse 524 | decodeBody(resp, &uploadsResp) 525 | 526 | return &uploadsResp, nil 527 | } 528 | 529 | func (c *duneClient) CreateUpload(req models.UploadsCreateRequest) (*models.UploadsCreateResponse, error) { 530 | createURL := fmt.Sprintf(createTableURLTemplate, c.env.Host) 531 | 532 | jsonData, err := json.Marshal(req) 533 | if err != nil { 534 | return nil, err 535 | } 536 | 537 | httpReq, err := http.NewRequest("POST", createURL, bytes.NewBuffer(jsonData)) 538 | if err != nil { 539 | return nil, err 540 | } 541 | 542 | resp, err := httpRequest(c.env.APIKey, httpReq) 543 | if err != nil { 544 | return nil, err 545 | } 546 | 547 | var createResp models.UploadsCreateResponse 548 | decodeBody(resp, &createResp) 549 | 550 | return &createResp, nil 551 | } 552 | 553 | func (c *duneClient) UploadCSV(req models.UploadsCSVRequest) (*models.UploadsCSVResponse, error) { 554 | uploadURL := fmt.Sprintf(uploadCSVURLTemplate, c.env.Host) 555 | 556 | jsonData, err := json.Marshal(req) 557 | if err != nil { 558 | return nil, err 559 | } 560 | 561 | httpReq, err := http.NewRequest("POST", uploadURL, bytes.NewBuffer(jsonData)) 562 | if err != nil { 563 | return nil, err 564 | } 565 | 566 | resp, err := httpRequest(c.env.APIKey, httpReq) 567 | if err != nil { 568 | return nil, err 569 | } 570 | 571 | var uploadResp models.UploadsCSVResponse 572 | decodeBody(resp, &uploadResp) 573 | 574 | return &uploadResp, nil 575 | } 576 | 577 | func (c *duneClient) DeleteUpload(namespace, tableName string) (*models.UploadsDeleteResponse, error) { 578 | deleteURL := fmt.Sprintf(deleteTableURLTemplate, c.env.Host, url.PathEscape(namespace), url.PathEscape(tableName)) 579 | 580 | req, err := http.NewRequest("DELETE", deleteURL, nil) 581 | if err != nil { 582 | return nil, err 583 | } 584 | 585 | resp, err := httpRequest(c.env.APIKey, req) 586 | if err != nil { 587 | return nil, err 588 | } 589 | 590 | var deleteResp models.UploadsDeleteResponse 591 | decodeBody(resp, &deleteResp) 592 | 593 | return &deleteResp, nil 594 | } 595 | 596 | func (c *duneClient) ClearUpload(namespace, tableName string) (*models.UploadsClearResponse, error) { 597 | clearURL := fmt.Sprintf(clearTableURLTemplate, c.env.Host, url.PathEscape(namespace), url.PathEscape(tableName)) 598 | 599 | req, err := http.NewRequest("POST", clearURL, nil) 600 | if err != nil { 601 | return nil, err 602 | } 603 | 604 | resp, err := httpRequest(c.env.APIKey, req) 605 | if err != nil { 606 | return nil, err 607 | } 608 | 609 | var clearResp models.UploadsClearResponse 610 | decodeBody(resp, &clearResp) 611 | 612 | return &clearResp, nil 613 | } 614 | 615 | func (c *duneClient) InsertIntoUpload( 616 | namespace, tableName, data, contentType string, 617 | ) (*models.UploadsInsertResponse, error) { 618 | insertURL := fmt.Sprintf(insertTableURLTemplate, c.env.Host, url.PathEscape(namespace), url.PathEscape(tableName)) 619 | 620 | req, err := http.NewRequest("POST", insertURL, bytes.NewBufferString(data)) 621 | if err != nil { 622 | return nil, err 623 | } 624 | 625 | req.Header.Set("Content-Type", contentType) 626 | 627 | resp, err := httpRequest(c.env.APIKey, req) 628 | if err != nil { 629 | return nil, err 630 | } 631 | 632 | var insertResp models.UploadsInsertResponse 633 | decodeBody(resp, &insertResp) 634 | 635 | return &insertResp, nil 636 | } 637 | 638 | func (c *duneClient) ListTables(limit, offset int) (*models.UploadsListResponse, error) { 639 | listURL := fmt.Sprintf(listTablesDeprecatedURLTemplate, c.env.Host) 640 | 641 | params := fmt.Sprintf("?limit=%d&offset=%d", limit, offset) 642 | 643 | req, err := http.NewRequest("GET", listURL+params, nil) 644 | if err != nil { 645 | return nil, err 646 | } 647 | 648 | resp, err := httpRequest(c.env.APIKey, req) 649 | if err != nil { 650 | return nil, err 651 | } 652 | 653 | var tablesResp models.UploadsListResponse 654 | decodeBody(resp, &tablesResp) 655 | 656 | return &tablesResp, nil 657 | } 658 | 659 | func (c *duneClient) CreateTable(req models.UploadsCreateRequest) (*models.UploadsCreateResponse, error) { 660 | createURL := fmt.Sprintf(createTableDeprecatedURLTemplate, c.env.Host) 661 | 662 | jsonData, err := json.Marshal(req) 663 | if err != nil { 664 | return nil, err 665 | } 666 | 667 | httpReq, err := http.NewRequest("POST", createURL, bytes.NewBuffer(jsonData)) 668 | if err != nil { 669 | return nil, err 670 | } 671 | 672 | resp, err := httpRequest(c.env.APIKey, httpReq) 673 | if err != nil { 674 | return nil, err 675 | } 676 | 677 | var createResp models.UploadsCreateResponse 678 | decodeBody(resp, &createResp) 679 | 680 | return &createResp, nil 681 | } 682 | 683 | func (c *duneClient) UploadCSVDeprecated(req models.UploadsCSVRequest) (*models.UploadsCSVResponse, error) { 684 | uploadURL := fmt.Sprintf(uploadCSVDeprecatedURLTemplate, c.env.Host) 685 | 686 | jsonData, err := json.Marshal(req) 687 | if err != nil { 688 | return nil, err 689 | } 690 | 691 | httpReq, err := http.NewRequest("POST", uploadURL, bytes.NewBuffer(jsonData)) 692 | if err != nil { 693 | return nil, err 694 | } 695 | 696 | resp, err := httpRequest(c.env.APIKey, httpReq) 697 | if err != nil { 698 | return nil, err 699 | } 700 | 701 | var uploadResp models.UploadsCSVResponse 702 | decodeBody(resp, &uploadResp) 703 | 704 | return &uploadResp, nil 705 | } 706 | 707 | func (c *duneClient) DeleteTable(namespace, tableName string) (*models.UploadsDeleteResponse, error) { 708 | deleteURL := fmt.Sprintf( 709 | deleteTableDeprecatedURLTemplate, c.env.Host, 710 | url.PathEscape(namespace), url.PathEscape(tableName), 711 | ) 712 | 713 | req, err := http.NewRequest("DELETE", deleteURL, nil) 714 | if err != nil { 715 | return nil, err 716 | } 717 | 718 | resp, err := httpRequest(c.env.APIKey, req) 719 | if err != nil { 720 | return nil, err 721 | } 722 | 723 | var deleteResp models.UploadsDeleteResponse 724 | decodeBody(resp, &deleteResp) 725 | 726 | return &deleteResp, nil 727 | } 728 | 729 | func (c *duneClient) ClearTable(namespace, tableName string) (*models.UploadsClearResponse, error) { 730 | clearURL := fmt.Sprintf( 731 | clearTableDeprecatedURLTemplate, c.env.Host, 732 | url.PathEscape(namespace), url.PathEscape(tableName), 733 | ) 734 | 735 | req, err := http.NewRequest("POST", clearURL, nil) 736 | if err != nil { 737 | return nil, err 738 | } 739 | 740 | resp, err := httpRequest(c.env.APIKey, req) 741 | if err != nil { 742 | return nil, err 743 | } 744 | 745 | var clearResp models.UploadsClearResponse 746 | decodeBody(resp, &clearResp) 747 | 748 | return &clearResp, nil 749 | } 750 | 751 | func (c *duneClient) InsertTable( 752 | namespace, tableName, data, contentType string, 753 | ) (*models.UploadsInsertResponse, error) { 754 | insertURL := fmt.Sprintf( 755 | insertTableDeprecatedURLTemplate, c.env.Host, 756 | url.PathEscape(namespace), url.PathEscape(tableName), 757 | ) 758 | 759 | req, err := http.NewRequest("POST", insertURL, bytes.NewBufferString(data)) 760 | if err != nil { 761 | return nil, err 762 | } 763 | 764 | req.Header.Set("Content-Type", contentType) 765 | 766 | resp, err := httpRequest(c.env.APIKey, req) 767 | if err != nil { 768 | return nil, err 769 | } 770 | 771 | var insertResp models.UploadsInsertResponse 772 | decodeBody(resp, &insertResp) 773 | 774 | return &insertResp, nil 775 | } 776 | --------------------------------------------------------------------------------