├── .gitignore ├── .golangci.yml ├── LICENSE ├── README.md ├── cmd └── main.go ├── config └── config.go ├── dune ├── dune.go ├── execution.go └── http.go ├── go.mod ├── go.sum ├── makefile └── models ├── cancel.go ├── execute.go ├── results.go ├── results_test.go └── status.go /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | ## CLI usage 62 | 63 | ### Build 64 | 65 | ``` 66 | go build -o dunecli cmd/main.go 67 | ``` 68 | 69 | You can use it from the repo directly or copy to a directory in your `$PATH` 70 | 71 | ### Usage 72 | 73 | The CLI has 2 main modes of operation. Run a query or retrieve information about 74 | an existing execution. In both cases, it will print out raw minified JSON to stdout, 75 | so if you want to prettify it, or select a specific key, you can pipe to [jq](https://stedolan.github.io/jq/). 76 | 77 | #### Execute a query 78 | 79 | To trigger a query execution and print the results once it's done: 80 | 81 | ```bash 82 | DUNE_API_KEY= ./dunecli -q 83 | ``` 84 | 85 | If the query has parameters you want to override, use: 86 | 87 | ```bash 88 | DUNE_API_KEY= ./dunecli -q -p '{"": ""}' 89 | ``` 90 | 91 | For numeric parameters, omit the quotes around the value. 92 | 93 | #### Get results for an existing execution 94 | 95 | If you already have an execution ID, you can retrieve its results (or state if it 96 | hasn't completed yet) with this: 97 | 98 | ```bash 99 | DUNE_API_KEY= ./dunecli -e 100 | ``` 101 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/dune.go: -------------------------------------------------------------------------------- 1 | package dune 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "time" 11 | 12 | "github.com/duneanalytics/duneapi-client-go/config" 13 | "github.com/duneanalytics/duneapi-client-go/models" 14 | ) 15 | 16 | // DuneClient represents all operations available to call externally 17 | type DuneClient interface { 18 | // New APIs to read results in a more flexible way 19 | // returns the results or status of an execution, depending on whether it has completed 20 | QueryResultsV2(executionID string, options models.ResultOptions) (*models.ResultsResponse, error) 21 | // returns the results of a QueryID, depending on whether it has completed 22 | ResultsByQueryID(queryID string, options models.ResultOptions) (*models.ResultsResponse, error) 23 | 24 | // RunQueryGetRows submits a query for execution and returns an Execution object 25 | RunQuery(queryID int, queryParameters map[string]any) (Execution, error) 26 | // RunQueryGetRows submits a query for execution, blocks until execution is finished, and returns just the result rows 27 | RunQueryGetRows(queryID int, queryParameters map[string]any) ([]map[string]any, error) 28 | 29 | // QueryCancel cancels the execution of an execution in the pending or executing state 30 | QueryCancel(executionID string) error 31 | 32 | // QueryExecute submits a query to execute with the provided parameters 33 | QueryExecute(queryID int, queryParameters map[string]any) (*models.ExecuteResponse, error) 34 | 35 | // QueryStatus returns the current execution status 36 | QueryStatus(executionID string) (*models.StatusResponse, error) 37 | 38 | // QueryResults returns the results or status of an execution, depending on whether it has completed 39 | // DEPRECATED, use QueryResultsV2 instead 40 | QueryResults(executionID string) (*models.ResultsResponse, error) 41 | 42 | // QueryResultsCSV returns the results of an execution, as CSV text stream if the execution has completed 43 | QueryResultsCSV(executionID string) (io.Reader, error) 44 | 45 | // QueryResultsByQueryID returns the results of the lastest execution for a given query ID 46 | // DEPRECATED, use ResultsByQueryID instead 47 | QueryResultsByQueryID(queryID string) (*models.ResultsResponse, error) 48 | 49 | // QueryResultsCSVByQueryID returns the results of the lastest execution for a given query ID 50 | // as CSV text stream if the execution has completed 51 | QueryResultsCSVByQueryID(queryID string) (io.Reader, error) 52 | } 53 | 54 | type duneClient struct { 55 | env *config.Env 56 | } 57 | 58 | var ( 59 | cancelURLTemplate = "%s/api/v1/execution/%s/cancel" 60 | executeURLTemplate = "%s/api/v1/query/%d/execute" 61 | statusURLTemplate = "%s/api/v1/execution/%s/status" 62 | executionResultsURLTemplate = "%s/api/v1/execution/%s/results" 63 | executionResultsCSVURLTemplate = "%s/api/v1/execution/%s/results/csv" 64 | queryResultsURLTemplate = "%s/api/v1/query/%s/results" 65 | queryResultsCSVURLTemplate = "%s/api/v1/query/%s/results/csv" 66 | ) 67 | 68 | var ErrorRetriesExhausted = errors.New("retries have been exhausted") 69 | 70 | // NewDuneClient instantiates a new stateless DuneAPI client. Env contains information about the 71 | // API key and target host (which shouldn't be changed, unless you want to run it through a custom proxy). 72 | func NewDuneClient(env *config.Env) *duneClient { 73 | return &duneClient{ 74 | env: env, 75 | } 76 | } 77 | 78 | func (c *duneClient) RunQuery(queryID int, queryParameters map[string]any) (Execution, error) { 79 | resp, err := c.QueryExecute(queryID, queryParameters) 80 | if err != nil { 81 | return nil, err 82 | } 83 | 84 | return &execution{ 85 | client: c, 86 | ID: resp.ExecutionID, 87 | }, nil 88 | } 89 | 90 | func (c *duneClient) RunQueryGetRows(queryID int, queryParameters map[string]any) ([]map[string]any, error) { 91 | execution, err := c.RunQuery(queryID, queryParameters) 92 | if err != nil { 93 | return nil, err 94 | } 95 | 96 | pollInterval := 5 * time.Second 97 | maxRetries := 10 98 | resp, err := execution.WaitGetResults(pollInterval, maxRetries) 99 | if err != nil { 100 | return nil, err 101 | } 102 | 103 | return resp.Result.Rows, nil 104 | } 105 | 106 | func (c *duneClient) QueryCancel(executionID string) error { 107 | cancelURL := fmt.Sprintf(cancelURLTemplate, c.env.Host, executionID) 108 | req, err := http.NewRequest("POST", cancelURL, nil) 109 | if err != nil { 110 | return err 111 | } 112 | resp, err := httpRequest(c.env.APIKey, req) 113 | if err != nil { 114 | return err 115 | } 116 | 117 | var cancelResp models.CancelResponse 118 | decodeBody(resp, &cancelResp) 119 | if err := cancelResp.HasError(); err != nil { 120 | return err 121 | } 122 | 123 | return nil 124 | } 125 | 126 | func (c *duneClient) QueryExecute(queryID int, queryParameters map[string]any) (*models.ExecuteResponse, error) { 127 | executeURL := fmt.Sprintf(executeURLTemplate, c.env.Host, queryID) 128 | jsonData, err := json.Marshal(models.ExecuteRequest{ 129 | QueryParameters: queryParameters, 130 | }) 131 | if err != nil { 132 | return nil, err 133 | } 134 | req, err := http.NewRequest("POST", executeURL, bytes.NewBuffer(jsonData)) 135 | if err != nil { 136 | return nil, err 137 | } 138 | resp, err := httpRequest(c.env.APIKey, req) 139 | if err != nil { 140 | return nil, err 141 | } 142 | 143 | var executeResp models.ExecuteResponse 144 | decodeBody(resp, &executeResp) 145 | if err := executeResp.HasError(); err != nil { 146 | return nil, err 147 | } 148 | 149 | return &executeResp, nil 150 | } 151 | 152 | func (c *duneClient) QueryStatus(executionID string) (*models.StatusResponse, error) { 153 | statusURL := fmt.Sprintf(statusURLTemplate, c.env.Host, executionID) 154 | req, err := http.NewRequest("GET", statusURL, nil) 155 | if err != nil { 156 | return nil, err 157 | } 158 | resp, err := httpRequest(c.env.APIKey, req) 159 | if err != nil { 160 | return nil, err 161 | } 162 | 163 | var statusResp models.StatusResponse 164 | decodeBody(resp, &statusResp) 165 | if err := statusResp.HasError(); err != nil { 166 | return nil, err 167 | } 168 | 169 | return &statusResp, nil 170 | } 171 | 172 | func (c *duneClient) getResults(url string, options models.ResultOptions) (*models.ResultsResponse, error) { 173 | var out models.ResultsResponse 174 | 175 | // track if we have request for a single page 176 | singlePage := options.Page != nil && (options.Page.Offset > 0 || options.Page.Limit > 0) 177 | 178 | if options.Page == nil { 179 | options.Page = &models.ResultPageOption{Limit: models.LimitRows} 180 | } 181 | 182 | for { 183 | url := fmt.Sprintf("%v?%v", url, options.ToURLValues().Encode()) 184 | req, err := http.NewRequest("GET", url, nil) 185 | if err != nil { 186 | return nil, err 187 | } 188 | resp, err := httpRequest(c.env.APIKey, req) 189 | if err != nil { 190 | return nil, err 191 | } 192 | 193 | var pageResp models.ResultsResponse 194 | decodeBody(resp, &pageResp) 195 | if err := pageResp.HasError(); err != nil { 196 | return nil, err 197 | } 198 | if singlePage { 199 | return &pageResp, nil 200 | } 201 | out.AddPageResult(&pageResp) 202 | 203 | if pageResp.NextOffset == nil { 204 | break 205 | } 206 | options.Page.Offset = *pageResp.NextOffset 207 | } 208 | 209 | return &out, nil 210 | } 211 | 212 | func (c *duneClient) getResultsCSV(url string) (io.Reader, error) { 213 | req, err := http.NewRequest("GET", url, nil) 214 | if err != nil { 215 | return nil, err 216 | } 217 | resp, err := httpRequest(c.env.APIKey, req) 218 | if err != nil { 219 | return nil, err 220 | } 221 | 222 | // we read whole result into ram here. if there was a paginated API we wouldn't need to 223 | var buf bytes.Buffer 224 | defer resp.Body.Close() 225 | _, err = buf.ReadFrom(resp.Body) 226 | return &buf, err 227 | } 228 | 229 | func (c *duneClient) QueryResultsV2(executionID string, options models.ResultOptions) (*models.ResultsResponse, error) { 230 | url := fmt.Sprintf(executionResultsURLTemplate, c.env.Host, executionID) 231 | return c.getResults(url, options) 232 | } 233 | 234 | func (c *duneClient) ResultsByQueryID(queryID string, options models.ResultOptions) (*models.ResultsResponse, error) { 235 | url := fmt.Sprintf(queryResultsURLTemplate, c.env.Host, queryID) 236 | return c.getResults(url, options) 237 | } 238 | 239 | func (c *duneClient) QueryResults(executionID string) (*models.ResultsResponse, error) { 240 | return c.QueryResultsV2(executionID, models.ResultOptions{}) 241 | } 242 | 243 | func (c *duneClient) QueryResultsByQueryID(queryID string) (*models.ResultsResponse, error) { 244 | return c.ResultsByQueryID(queryID, models.ResultOptions{}) 245 | } 246 | 247 | func (c *duneClient) QueryResultsCSV(executionID string) (io.Reader, error) { 248 | url := fmt.Sprintf(executionResultsCSVURLTemplate, c.env.Host, executionID) 249 | return c.getResultsCSV(url) 250 | } 251 | 252 | func (c *duneClient) QueryResultsCSVByQueryID(queryID string) (io.Reader, error) { 253 | url := fmt.Sprintf(queryResultsCSVURLTemplate, c.env.Host, queryID) 254 | return c.getResultsCSV(url) 255 | } 256 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 { 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 ExecuteResponse struct { 13 | ExecutionID string `json:"execution_id,omitempty"` 14 | State string `json:"state,omitempty"` 15 | } 16 | 17 | func (e ExecuteResponse) HasError() error { 18 | // 01 is the expected prefix for an ULID string 19 | if len(e.ExecutionID) != 26 || !strings.HasPrefix(e.ExecutionID, "01") { 20 | return fmt.Errorf("bad execution id: %v", e.ExecutionID) 21 | } 22 | if !strings.HasPrefix(e.State, "QUERY_STATE_") { 23 | return fmt.Errorf("bad state: %v", e.State) 24 | } 25 | return nil 26 | } 27 | -------------------------------------------------------------------------------- /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 *any `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 | -------------------------------------------------------------------------------- /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/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 | ResultMetadata *ResultMetadata `json:"result_metadata,omitempty"` 19 | } 20 | 21 | func (s StatusResponse) HasError() error { 22 | if s.ExecutionID == "" { 23 | return errors.New("missing executionID") 24 | } 25 | if !strings.HasPrefix(s.State, "QUERY_STATE_") { 26 | return fmt.Errorf("bad state: %v", s.State) 27 | } 28 | 29 | if s.State == "QUERY_STATE_COMPLETED" { 30 | if s.ResultMetadata == nil { 31 | return errors.New("missing results metadata") 32 | } 33 | if s.ExecutionEndedAt == nil { 34 | return errors.New("missing execution endedAt") 35 | } 36 | } else { 37 | if s.ResultMetadata != nil { 38 | return fmt.Errorf("cannot have results metadata if state: %v", s.State) 39 | } 40 | } 41 | 42 | if s.State == "QUERY_STATE_CANCELLED" { 43 | if s.CancelledAt == nil { 44 | return errors.New("missing cancelled at") 45 | } 46 | } else { 47 | if s.CancelledAt != nil { 48 | return errors.New("field CancelledAt shouldn't be present") 49 | } 50 | } 51 | return nil 52 | } 53 | --------------------------------------------------------------------------------