├── .github └── workflows │ ├── auto-merge.yml │ └── ci.yml ├── .gitignore ├── .golangci.yml ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.md ├── admin.go ├── admin_test.go ├── admin_without_ctx.go ├── alert.go ├── alert_test.go ├── alert_without_ctx.go ├── client.go ├── client_private_test.go ├── client_test.go ├── codecov.yml ├── compose.yaml ├── config.go ├── config_test.go ├── config_without_ctx.go ├── dashboard.go ├── dashboard_test.go ├── dashboard_without_ctx.go ├── data_source.go ├── data_source_test.go ├── data_source_without_ctx.go ├── destinations.go ├── destinations_test.go ├── destinations_without_ctx.go ├── doc.go ├── etc └── redash.sql ├── event.go ├── event_test.go ├── event_without_ctx.go ├── examples ├── go.mod ├── go.sum └── main.go ├── go.mod ├── go.sum ├── group.go ├── group_test.go ├── group_without_ctx.go ├── internal └── util │ ├── http.go │ └── http_test.go ├── job.go ├── job_test.go ├── job_without_ctx.go ├── organization.go ├── organization_test.go ├── organization_without_ctx.go ├── ping.go ├── ping_test.go ├── ping_without_ctx.go ├── query.go ├── query_snippet.go ├── query_snippet_test.go ├── query_snippet_without_ctx.go ├── query_test.go ├── query_with_params_test.go ├── query_without_ctx.go ├── redash_go_test.go ├── renovate.json ├── session.go ├── session_test.go ├── session_without_ctx.go ├── settings.go ├── settings_test.go ├── settings_without_ctx.go ├── status.go ├── status_test.go ├── status_without_ctx.go ├── tools └── withoutctx.go ├── user.go ├── user_test.go ├── user_without_ctx.go ├── visualization.go ├── visualization_test.go ├── visualization_without_ctx.go ├── widget.go ├── widget_test.go └── widget_without_ctx.go /.github/workflows/auto-merge.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot auto-merge 2 | on: pull_request 3 | 4 | permissions: 5 | contents: write 6 | pull-requests: write 7 | 8 | jobs: 9 | dependabot: 10 | runs-on: ubuntu-latest 11 | if: ${{ github.actor == 'renovate[bot]' }} 12 | steps: 13 | - name: Enable auto-merge for Dependabot PRs 14 | run: gh pr merge --auto --merge "$PR_URL" 15 | env: 16 | PR_URL: ${{github.event.pull_request.html_url}} 17 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 18 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.ref }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | test: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v5 18 | - uses: actions/setup-go@v6 19 | with: 20 | go-version-file: go.mod 21 | cache: false 22 | - uses: golangci/golangci-lint-action@v8 23 | - run: make gen && git diff --exit-code 24 | - name: Start services 25 | run: | 26 | for i in {1..60}; do docker compose up -d --quiet-pull && break; sleep 1; done 27 | for i in {1..60}; do pg_isready -U postgres -h 127.0.0.1 -p 15432 && break; sleep 1; done 28 | - run: make redash-setup 29 | - run: make redash-upgrade-db 30 | - run: make vet 31 | - run: make testacc TEST_OPTS='-coverprofile=coverage.out' 32 | - name: Upload coverage reports to Codecov 33 | uses: codecov/codecov-action@v5.5.1 34 | with: 35 | token: ${{ secrets.CODECOV_TOKEN }} 36 | -------------------------------------------------------------------------------- /.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 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | enable: 4 | - misspell 5 | exclusions: 6 | generated: lax 7 | presets: 8 | - comments 9 | - common-false-positives 10 | - legacy 11 | - std-error-handling 12 | formatters: 13 | exclusions: 14 | generated: lax 15 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [2.6.2] - 2025-03-10 4 | 5 | ### Changed 6 | 7 | * Deprecated `Debug` field. 8 | 9 | ## [2.6.1] - 2025-03-09 10 | 11 | ### Changed 12 | 13 | * Add `IsDraft` to `UpdateQueryInput` struct. 14 | 15 | ## [2.6.0] - 2025-03-09 16 | 17 | ### Added 18 | 19 | * Add `PublishQuery()/UnpublishQuery()` methods. 20 | 21 | ## [2.5.0] - 2025-03-08 22 | 23 | ### Added 24 | 25 | * Support regex/enum/query parameter type. 26 | 27 | ## [2.4.1] - 2025-01-25 28 | 29 | ### Added 30 | 31 | * Add `GetQueryResultByID()` methods. 32 | 33 | ## [2.4.0] - 2025-01-25 34 | 35 | ### Changed 36 | 37 | * `ExecQueryJSON()` supports `apply_auto_limit`. 38 | * `RefreshQuery()` supports `apply_auto_limit`. (**breaking change**) 39 | 40 | ## [2.3.1] - 2024-06-14 41 | 42 | ### Changed 43 | 44 | * chore: Add `redash._debugOut` var for testing. 45 | 46 | ## [2.3.0] - 2024-06-10 47 | 48 | ### Added 49 | 50 | * Add `WaitQueryXXX()` methods. 51 | 52 | ## [2.2.0] - 2024-06-9 53 | 54 | ### Changed 55 | 56 | * `ExecQueryJSONInput()` supports `max_age=0`. (https://github.com/winebarrel/redash-go/issues/136) 57 | 58 | ## [2.1.0] - 2023-10-23 59 | 60 | ### Added 61 | 62 | * Add `ExecQueryJSONInput` struct. 63 | 64 | ## [2.1.0] - 2023-10-23 65 | 66 | ### Added 67 | 68 | * Add `ExecQueryJSONInput` struct. 69 | 70 | ## [2.0.1] - 2023-10-14 71 | 72 | ### Changed 73 | 74 | * Update doc.go description. 75 | 76 | ## [2.0.0] - 2023-10-14 77 | 78 | ### Removed 79 | 80 | * Drop v8 support. 81 | 82 | ## [1.4.1] - 2023-09-14 83 | 84 | ### Added 85 | 86 | * Add job status constants. 87 | 88 | ### Changed 89 | 90 | * Update modules. 91 | 92 | ## [1.4.0] - 2023-04-25 93 | 94 | ### Added 95 | 96 | * Change `UpdateInput.Tags` type. (`[]string` -> `*[]string`) 97 | * Remove `Client.RemoveQueryTags()`. 98 | 99 | ## [1.3.0] - 2023-04-25 100 | 101 | ### Added 102 | 103 | * Add `Client.RemoveQueryTags()`. 104 | 105 | ## [1.2.1] - 2023-04-25 106 | 107 | ### Changed 108 | 109 | * Remove "omitempty" from `UpdateQueryInput.Tags`. 110 | 111 | ## [1.2.0] - 2023-04-25 112 | 113 | ### Added 114 | 115 | * Add `Client.UpdateUser()`. 116 | 117 | ### Changed 118 | 119 | * Change generator tool path. 120 | 121 | ## [1.1.2] - 2023-04-23 122 | 123 | ### Changed 124 | 125 | * Add `AlertOptions.Muted`. 126 | 127 | ## [1.1.1] - 2023-04-23 128 | 129 | ### Changed 130 | 131 | * Remove "omitempty" from `UpdateAlertInput.Rearm`. 132 | 133 | ## [1.1.0] - 2023-04-23 134 | 135 | ### Changed 136 | 137 | - Support alert template fields. 138 | 139 | ## [1.0.1] - 2023-04-16 140 | 141 | ### Changed 142 | 143 | - Rename docker-compose.yml to compose.yaml. 144 | - Update modules. 145 | 146 | ## [1.0.0] - 2023-03-07 147 | 148 | ### Added 149 | 150 | - First stable release. 151 | 152 | 153 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Genki Sugawara 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all 2 | all: vet test 3 | 4 | .PHONY: vet 5 | vet: 6 | go vet ./... 7 | 8 | .PHONY: test 9 | test: 10 | go test -v -count=1 ./... $(TEST_OPTS) 11 | 12 | .PHONY: testacc 13 | testacc: 14 | $(MAKE) test TEST_ACC=1 15 | 16 | .PHONY: lint 17 | lint: 18 | golangci-lint run 19 | 20 | .PHONY: gen 21 | gen: 22 | go generate 23 | 24 | .PHONY: redash-setup 25 | redash-setup: 26 | psql -U postgres -h localhost -p 15432 -f etc/redash.sql 27 | $(MAKE) redash-upgrade-db 28 | 29 | .PHONY: redash-upgrade-db 30 | redash-upgrade-db: 31 | docker compose run --rm server manage db upgrade 32 | 33 | .PHONY: redash-create-db 34 | redash-create-db: 35 | docker compose run --rm server create_db 36 | 37 | .PHONY: pg-dump 38 | pg-dump: 39 | pg_dump -U postgres -h localhost -p 15432 -c --if-exists > etc/redash.sql 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # redash-go 2 | 3 | [![CI](https://github.com/winebarrel/redash-go/actions/workflows/ci.yml/badge.svg)](https://github.com/winebarrel/redash-go/actions/workflows/ci.yml) 4 | [![Go Reference](https://pkg.go.dev/badge/github.com/winebarrel/redash-go/v2.svg)](https://pkg.go.dev/github.com/winebarrel/redash-go/v2) 5 | [![GitHub tag (latest by date)](https://img.shields.io/github/v/tag/winebarrel/redash-go)](https://pkg.go.dev/github.com/winebarrel/redash-go/v2?tab=versions) 6 | [![Go Report Card](https://goreportcard.com/badge/github.com/winebarrel/redash-go/v2)](https://goreportcard.com/report/github.com/winebarrel/redash-go/v2) 7 | [![codecov](https://codecov.io/gh/winebarrel/redash-go/graph/badge.svg?token=9E21C7D54I)](https://codecov.io/gh/winebarrel/redash-go) 8 | 9 | ## Overview 10 | 11 | Redash API client for Go that supports almost all APIs. 12 | 13 | ## Usage 14 | 15 | ```go 16 | package main 17 | 18 | import ( 19 | "bytes" 20 | "context" 21 | "fmt" 22 | "time" 23 | 24 | "github.com/winebarrel/redash-go/v2" 25 | ) 26 | 27 | func main() { 28 | client := redash.MustNewClient("https://redash.example.com", "") 29 | ctx := context.Background() 30 | 31 | ds, err := client.CreateDataSource(ctx, &redash.CreateDataSourceInput{ 32 | Name: "postgres", 33 | Type: "pg", 34 | // see https://github.com/getredash/redash/blob/v25.1/redash/query_runner/pg.py#L149-L153 35 | Options: map[string]any{ 36 | "dbname": "postgres", 37 | "host": "postgres", 38 | "port": 5432, 39 | "user": "postgres", 40 | }, 41 | }) 42 | 43 | if err != nil { 44 | panic(err) 45 | } 46 | 47 | query, err := client.CreateQuery(ctx, &redash.CreateQueryInput{ 48 | DataSourceID: ds.ID, 49 | Name: "my-query1", 50 | Query: "select 1", 51 | }) 52 | 53 | if err != nil { 54 | panic(err) 55 | } 56 | 57 | var buf bytes.Buffer 58 | 59 | // The API prefers to return a cached result. 60 | // If a cached result is not available then a new execution job begins and the job object is returned. 61 | // see https://redash.io/help/user-guide/integrations-and-api/api#Queries 62 | job, err := client.ExecQueryJSON(ctx, query.ID, nil, &buf) 63 | 64 | if err != nil { 65 | panic(err) 66 | } 67 | 68 | err = client.WaitQueryJSON(ctx, query.ID, job, nil, &buf) 69 | 70 | if err != nil { 71 | panic(err) 72 | } 73 | 74 | fmt.Println(buf.String()) 75 | } 76 | ``` 77 | 78 | ### `max_age=0` 79 | 80 | ```go 81 | input := &redash.ExecQueryJSONInput{ 82 | WithoutOmittingMaxAge: true, 83 | } 84 | 85 | job, err := client.ExecQueryJSON(ctx, query.ID, input, nil) 86 | 87 | if err != nil { 88 | panic(err) 89 | } 90 | 91 | err = client.WaitQueryJSON(ctx, query.ID, job, nil, &buf) 92 | 93 | if err != nil { 94 | panic(err) 95 | } 96 | 97 | fmt.Println(buf.String()) 98 | ``` 99 | 100 | ### Set debug mode 101 | 102 | ```go 103 | client := redash.MustNewClient("https://redash.example.com", "") 104 | client.SetDebug(true) 105 | client.GetStatus(context.Background()) 106 | ``` 107 | 108 | ``` 109 | % go run example.go 110 | ---request begin--- 111 | GET /status.json HTTP/1.1 112 | Host: redash.example.com 113 | Authorization: Key 114 | Content-Type: application/json 115 | User-Agent: redash-go 116 | 117 | 118 | ---request end--- 119 | ---response begin--- 120 | HTTP/1.1 200 OK 121 | ... 122 | 123 | {"dashboards_count": 0, "database_metrics": {"metrics": [ ... 124 | ``` 125 | 126 | ### With custom HTTP client 127 | 128 | ```go 129 | hc := &http.Client{ 130 | Timeout: 3 * time.Second, 131 | } 132 | client := redash.MustNewClientWithHTTPClient("https://redash.example.com", "", hc) 133 | client.GetStatus(context.Background()) 134 | ``` 135 | 136 | ### Without context.Context 137 | 138 | ```go 139 | client0 := redash.MustNewClient("https://redash.example.com", "") 140 | client := client0.WithoutContext() 141 | client.GetStatus() 142 | ``` 143 | 144 | ### NewClient with error 145 | 146 | ```go 147 | client, err := redash.NewClient("https://redash.example.com", "") 148 | ``` 149 | 150 | ## Test 151 | 152 | ```sh 153 | make test 154 | ``` 155 | 156 | ### Acceptance Test 157 | 158 | ```sh 159 | docker compose up -d 160 | make redash-setup 161 | make testacc 162 | ``` 163 | 164 | **NOTE:** 165 | * local Redash URL: http://localhost:5001 166 | * email: `admin@example.com` 167 | * password: `password` 168 | * mail server URL: http://localhost:10081 169 | 170 | ## Related Links 171 | 172 | * https://redash.io/help/user-guide/integrations-and-api/api 173 | * https://github.com/winebarrel/terraform-provider-redash 174 | -------------------------------------------------------------------------------- /admin.go: -------------------------------------------------------------------------------- 1 | //go:generate go run tools/withoutctx.go 2 | package redash 3 | 4 | import ( 5 | "context" 6 | 7 | "github.com/winebarrel/redash-go/v2/internal/util" 8 | ) 9 | 10 | type AdminQueriesOutdated struct { 11 | Queries []Query `json:"queries"` 12 | UpdatedAt string `json:"updated_at"` 13 | } 14 | 15 | func (client *Client) GetAdminQueriesOutdated(ctx context.Context) (*AdminQueriesOutdated, error) { 16 | res, close, err := client.Get(ctx, "/api/admin/queries/outdated", nil) 17 | defer close() 18 | 19 | if err != nil { 20 | return nil, err 21 | } 22 | 23 | outdated := &AdminQueriesOutdated{} 24 | 25 | if err := util.UnmarshalBody(res, &outdated); err != nil { 26 | return nil, err 27 | } 28 | 29 | return outdated, nil 30 | } 31 | 32 | type AdminQuerisRqStatus struct { 33 | Queues AdminQuerisRqStatusQueues `json:"queues"` 34 | Workers []AdminQuerisRqStatusWorker `json:"workers"` 35 | } 36 | 37 | type AdminQuerisRqStatusQueues struct { 38 | Default *AdminQuerisRqStatusDefault `json:"default"` 39 | Emails *AdminQuerisRqStatusEmails `json:"emails"` 40 | Periodic *AdminQuerisRqStatusPeriodic `json:"periodic"` 41 | Queries *AdminQuerisRqStatusQueries `json:"queries"` 42 | Schemas *AdminQuerisRqStatusSchemas `json:"schemas"` 43 | } 44 | 45 | type AdminQuerisRqStatusDefault struct { 46 | Name string `json:"name"` 47 | Queued int `json:"queued"` 48 | Started []any `json:"started"` 49 | } 50 | 51 | type AdminQuerisRqStatusEmails struct { 52 | Name string `json:"name"` 53 | Queued int `json:"queued"` 54 | Started []any `json:"started"` 55 | } 56 | 57 | type AdminQuerisRqStatusPeriodic struct { 58 | Name string `json:"name"` 59 | Queued int `json:"queued"` 60 | Started []any `json:"started"` 61 | } 62 | 63 | type AdminQuerisRqStatusQueries struct { 64 | Name string `json:"name"` 65 | Queued int `json:"queued"` 66 | Started []any `json:"started"` 67 | } 68 | 69 | type AdminQuerisRqStatusSchemas struct { 70 | Name string `json:"name"` 71 | Queued int `json:"queued"` 72 | Started []any `json:"started"` 73 | } 74 | 75 | type AdminQuerisRqStatusWorker struct { 76 | BirthDate string `json:"birth_date"` 77 | CurrentJob string `json:"current_job"` 78 | FailedJobs int `json:"failed_jobs"` 79 | Hostname string `json:"hostname"` 80 | LastHeartbeat string `json:"last_heartbeat"` 81 | Name string `json:"name"` 82 | Pid int `json:"pid"` 83 | Queues string `json:"queues"` 84 | State string `json:"state"` 85 | SuccessfulJobs int `json:"successful_jobs"` 86 | TotalWorkingTime float64 `json:"total_working_time"` 87 | } 88 | 89 | func (client *Client) GetAdminQueriesRqStatus(ctx context.Context) (*AdminQuerisRqStatus, error) { 90 | res, close, err := client.Get(ctx, "/api/admin/queries/rq_status", nil) 91 | defer close() 92 | 93 | if err != nil { 94 | return nil, err 95 | } 96 | 97 | rqStatus := &AdminQuerisRqStatus{} 98 | 99 | if err := util.UnmarshalBody(res, &rqStatus); err != nil { 100 | return nil, err 101 | } 102 | 103 | return rqStatus, nil 104 | } 105 | -------------------------------------------------------------------------------- /admin_test.go: -------------------------------------------------------------------------------- 1 | package redash_test 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/jarcoal/httpmock" 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | "github.com/winebarrel/redash-go/v2" 12 | ) 13 | 14 | func Test_GetAdminQueriesOutdated_OK(t *testing.T) { 15 | assert := assert.New(t) 16 | httpmock.Activate() 17 | defer httpmock.DeactivateAndReset() 18 | 19 | httpmock.RegisterResponder(http.MethodGet, "https://redash.example.com/api/admin/queries/outdated", func(req *http.Request) (*http.Response, error) { 20 | assert.Equal( 21 | http.Header( 22 | http.Header{ 23 | "Authorization": []string{"Key " + testRedashAPIKey}, 24 | "Content-Type": []string{"application/json"}, 25 | "User-Agent": []string{"redash-go"}, 26 | }, 27 | ), 28 | req.Header, 29 | ) 30 | return httpmock.NewStringResponse(http.StatusOK, ` 31 | { 32 | "queries": [], 33 | "updated_at": "1676390846.0004709" 34 | } 35 | `), nil 36 | }) 37 | 38 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 39 | res, err := client.GetAdminQueriesOutdated(context.Background()) 40 | assert.NoError(err) 41 | assert.Equal(&redash.AdminQueriesOutdated{ 42 | Queries: []redash.Query{}, 43 | UpdatedAt: "1676390846.0004709", 44 | }, res) 45 | } 46 | 47 | func Test_GetAdminQueriesOutdated_Err_5xx(t *testing.T) { 48 | assert := assert.New(t) 49 | httpmock.Activate() 50 | defer httpmock.DeactivateAndReset() 51 | 52 | httpmock.RegisterResponder(http.MethodGet, "https://redash.example.com/api/admin/queries/outdated", func(req *http.Request) (*http.Response, error) { 53 | return httpmock.NewStringResponse(http.StatusServiceUnavailable, `error`), nil 54 | }) 55 | 56 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 57 | _, err := client.GetAdminQueriesOutdated(context.Background()) 58 | assert.ErrorContains(err, "GET /api/admin/queries/outdated failed: HTTP status code not OK: 503 Service Unavailable\nerror") 59 | } 60 | 61 | func Test_GetAdminQueriesOutdated_IOErr(t *testing.T) { 62 | assert := assert.New(t) 63 | httpmock.Activate() 64 | defer httpmock.DeactivateAndReset() 65 | 66 | httpmock.RegisterResponder(http.MethodGet, "https://redash.example.com/api/admin/queries/outdated", func(req *http.Request) (*http.Response, error) { 67 | return testIOErrResp, nil 68 | }) 69 | 70 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 71 | _, err := client.GetAdminQueriesOutdated(context.Background()) 72 | assert.ErrorContains(err, "read response body failed: IO error") 73 | } 74 | 75 | func Test_GetAdminQueriesRqStatus_OK(t *testing.T) { 76 | assert := assert.New(t) 77 | httpmock.Activate() 78 | defer httpmock.DeactivateAndReset() 79 | 80 | httpmock.RegisterResponder(http.MethodGet, "https://redash.example.com/api/admin/queries/rq_status", func(req *http.Request) (*http.Response, error) { 81 | assert.Equal( 82 | http.Header( 83 | http.Header{ 84 | "Authorization": []string{"Key " + testRedashAPIKey}, 85 | "Content-Type": []string{"application/json"}, 86 | "User-Agent": []string{"redash-go"}, 87 | }, 88 | ), 89 | req.Header, 90 | ) 91 | return httpmock.NewStringResponse(http.StatusOK, ` 92 | { 93 | "queues": { 94 | "default": { 95 | "name": "default", 96 | "queued": 0, 97 | "started": [] 98 | }, 99 | "emails": { 100 | "name": "emails", 101 | "queued": 0, 102 | "started": [] 103 | }, 104 | "periodic": { 105 | "name": "periodic", 106 | "queued": 0, 107 | "started": [] 108 | }, 109 | "queries": { 110 | "name": "queries", 111 | "queued": 0, 112 | "started": [ 113 | { 114 | "enqueued_at": "2023-02-14T16:19:51.947", 115 | "id": "8cd29f9b-4227-4fd1-9197-3fe3ccdd5562", 116 | "meta": { 117 | "data_source_id": 4, 118 | "org_id": 1, 119 | "query_id": "7", 120 | "scheduled": false, 121 | "user_id": 1 122 | }, 123 | "name": "redash.tasks.queries.execution.execute_query", 124 | "origin": "queries", 125 | "started_at": "2023-02-14T16:19:51.963" 126 | } 127 | ] 128 | }, 129 | "schemas": { 130 | "name": "schemas", 131 | "queued": 0, 132 | "started": [] 133 | } 134 | }, 135 | "workers": [ 136 | { 137 | "birth_date": "2023-02-14T16:04:39.454", 138 | "current_job": null, 139 | "failed_jobs": 0, 140 | "hostname": "2e21d2a7bae6", 141 | "last_heartbeat": "2023-02-14T16:19:53.462", 142 | "name": "8fb4d57148254783b6e63a0e420738ac", 143 | "pid": 16, 144 | "queues": "periodic, emails, default, scheduled_queries, queries, schemas", 145 | "state": "busy", 146 | "successful_jobs": 49, 147 | "total_working_time": 2.224891 148 | }, 149 | { 150 | "birth_date": "2023-02-14T16:04:38.736", 151 | "current_job": "8cd29f9b-4227-4fd1-9197-3fe3ccdd5562 (execute_query)", 152 | "failed_jobs": 0, 153 | "hostname": "2e21d2a7bae6", 154 | "last_heartbeat": "2023-02-14T16:19:51.963", 155 | "name": "980ad4d9b3994134abf281d93aaec1b7", 156 | "pid": 15, 157 | "queues": "periodic, emails, default, scheduled_queries, queries, schemas", 158 | "state": "busy", 159 | "successful_jobs": 44, 160 | "total_working_time": 62.508043 161 | } 162 | ] 163 | } 164 | `), nil 165 | }) 166 | 167 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 168 | res, err := client.GetAdminQueriesRqStatus(context.Background()) 169 | assert.NoError(err) 170 | assert.Equal(&redash.AdminQuerisRqStatus{ 171 | Queues: redash.AdminQuerisRqStatusQueues{ 172 | Default: &redash.AdminQuerisRqStatusDefault{ 173 | Name: "default", 174 | Queued: 0, 175 | Started: []any{}, 176 | }, 177 | Emails: &redash.AdminQuerisRqStatusEmails{ 178 | Name: "emails", 179 | Queued: 0, 180 | Started: []any{}, 181 | }, 182 | Periodic: &redash.AdminQuerisRqStatusPeriodic{ 183 | Name: "periodic", 184 | Queued: 0, 185 | Started: []any{}, 186 | }, 187 | Queries: &redash.AdminQuerisRqStatusQueries{ 188 | Name: "queries", 189 | Queued: 0, 190 | Started: []any{ 191 | map[string]any{ 192 | "enqueued_at": "2023-02-14T16:19:51.947", 193 | "id": "8cd29f9b-4227-4fd1-9197-3fe3ccdd5562", 194 | "meta": map[string]any{ 195 | "data_source_id": float64(4), 196 | "org_id": float64(1), 197 | "query_id": "7", 198 | "scheduled": false, 199 | "user_id": float64(1), 200 | }, 201 | "name": "redash.tasks.queries.execution.execute_query", 202 | "origin": "queries", 203 | "started_at": "2023-02-14T16:19:51.963", 204 | }, 205 | }, 206 | }, 207 | Schemas: &redash.AdminQuerisRqStatusSchemas{ 208 | Name: "schemas", 209 | Queued: 0, 210 | Started: []any{}, 211 | }, 212 | }, 213 | Workers: []redash.AdminQuerisRqStatusWorker{ 214 | { 215 | BirthDate: "2023-02-14T16:04:39.454", 216 | CurrentJob: "", 217 | FailedJobs: 0, 218 | Hostname: "2e21d2a7bae6", 219 | LastHeartbeat: "2023-02-14T16:19:53.462", 220 | Name: "8fb4d57148254783b6e63a0e420738ac", 221 | Pid: 16, 222 | Queues: "periodic, emails, default, scheduled_queries, queries, schemas", 223 | State: "busy", 224 | SuccessfulJobs: 49, 225 | TotalWorkingTime: 2.224891, 226 | }, 227 | { 228 | BirthDate: "2023-02-14T16:04:38.736", 229 | CurrentJob: "8cd29f9b-4227-4fd1-9197-3fe3ccdd5562 (execute_query)", 230 | FailedJobs: 0, 231 | Hostname: "2e21d2a7bae6", 232 | LastHeartbeat: "2023-02-14T16:19:51.963", 233 | Name: "980ad4d9b3994134abf281d93aaec1b7", 234 | Pid: 15, 235 | Queues: "periodic, emails, default, scheduled_queries, queries, schemas", 236 | State: "busy", 237 | SuccessfulJobs: 44, 238 | TotalWorkingTime: 62.508043, 239 | }, 240 | }, 241 | }, res) 242 | } 243 | 244 | func Test_GetAdminQueriesRqStatus_Err_5xx(t *testing.T) { 245 | assert := assert.New(t) 246 | httpmock.Activate() 247 | defer httpmock.DeactivateAndReset() 248 | 249 | httpmock.RegisterResponder(http.MethodGet, "https://redash.example.com/api/admin/queries/rq_status", func(req *http.Request) (*http.Response, error) { 250 | return httpmock.NewStringResponse(http.StatusServiceUnavailable, `error`), nil 251 | }) 252 | 253 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 254 | _, err := client.GetAdminQueriesRqStatus(context.Background()) 255 | assert.ErrorContains(err, "GET /api/admin/queries/rq_status failed: HTTP status code not OK: 503 Service Unavailable\nerror") 256 | } 257 | 258 | func Test_GetAdminQueriesRqStatus_IOErr(t *testing.T) { 259 | assert := assert.New(t) 260 | httpmock.Activate() 261 | defer httpmock.DeactivateAndReset() 262 | 263 | httpmock.RegisterResponder(http.MethodGet, "https://redash.example.com/api/admin/queries/rq_status", func(req *http.Request) (*http.Response, error) { 264 | return testIOErrResp, nil 265 | }) 266 | 267 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 268 | _, err := client.GetAdminQueriesRqStatus(context.Background()) 269 | assert.ErrorContains(err, "read response body failed: IO error") 270 | } 271 | 272 | func Test_Admin_Acc(t *testing.T) { 273 | if !testAcc { 274 | t.Skip() 275 | } 276 | 277 | assert := assert.New(t) 278 | require := require.New(t) 279 | client, _ := redash.NewClient(testRedashEndpoint, testRedashAPIKey) 280 | outdated, err := client.GetAdminQueriesOutdated(context.Background()) 281 | require.NoError(err) 282 | assert.NotEmpty(outdated.UpdatedAt) 283 | 284 | rqStatus, err := client.GetAdminQueriesRqStatus(context.Background()) 285 | require.NoError(err) 286 | assert.Equal("default", rqStatus.Queues.Default.Name) 287 | } 288 | -------------------------------------------------------------------------------- /admin_without_ctx.go: -------------------------------------------------------------------------------- 1 | // Code generated from admin.go using tools/withoutctx.go; DO NOT EDIT. 2 | 3 | package redash 4 | 5 | import "context" 6 | 7 | func (client *ClientWithoutContext) GetAdminQueriesOutdated() (*AdminQueriesOutdated, error) { 8 | return client.withCtx.GetAdminQueriesOutdated(context.Background()) 9 | } 10 | 11 | func (client *ClientWithoutContext) GetAdminQueriesRqStatus() (*AdminQuerisRqStatus, error) { 12 | return client.withCtx.GetAdminQueriesRqStatus(context.Background()) 13 | } 14 | -------------------------------------------------------------------------------- /alert.go: -------------------------------------------------------------------------------- 1 | //go:generate go run tools/withoutctx.go 2 | package redash 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | "time" 8 | 9 | "github.com/winebarrel/redash-go/v2/internal/util" 10 | ) 11 | 12 | type Alert struct { 13 | CreatedAt time.Time `json:"created_at"` 14 | ID int `json:"id"` 15 | LastTriggeredAt time.Time `json:"last_triggered_at"` 16 | Name string `json:"name"` 17 | Options AlertOptions `json:"options"` 18 | Query Query `json:"query"` 19 | Rearm int `json:"rearm"` 20 | State string `json:"state"` 21 | UpdatedAt time.Time `json:"updated_at"` 22 | User User `json:"user"` 23 | } 24 | 25 | type AlertOptions struct { 26 | Column string `json:"column"` 27 | Op string `json:"op"` 28 | Value int `json:"value"` 29 | CustomSubject string `json:"custom_subject"` 30 | CustomBody string `json:"custom_body"` 31 | // Deprecated: for backward compatibility 32 | Template string `json:"template"` 33 | Muted bool `json:"muted"` 34 | } 35 | 36 | type AlertSubscription struct { 37 | AlertID int `json:"alert_id"` 38 | Destination *Destination `json:"destination"` 39 | ID int `json:"id"` 40 | User User `json:"user"` 41 | } 42 | 43 | func (client *Client) ListAlerts(ctx context.Context) ([]Alert, error) { 44 | res, close, err := client.Get(ctx, "api/alerts", nil) 45 | defer close() 46 | 47 | if err != nil { 48 | return nil, err 49 | } 50 | 51 | alerts := []Alert{} 52 | 53 | if err := util.UnmarshalBody(res, &alerts); err != nil { 54 | return nil, err 55 | } 56 | 57 | return alerts, nil 58 | } 59 | 60 | func (client *Client) GetAlert(ctx context.Context, id int) (*Alert, error) { 61 | res, close, err := client.Get(ctx, fmt.Sprintf("api/alerts/%d", id), nil) 62 | defer close() 63 | 64 | if err != nil { 65 | return nil, err 66 | } 67 | 68 | alert := &Alert{} 69 | 70 | if err := util.UnmarshalBody(res, &alert); err != nil { 71 | return nil, err 72 | } 73 | 74 | return alert, nil 75 | } 76 | 77 | type CreateAlertInput struct { 78 | Name string `json:"name"` 79 | Options CreateAlertOptions `json:"options"` 80 | QueryId int `json:"query_id"` 81 | Rearm int `json:"rearm,omitempty"` 82 | } 83 | 84 | type CreateAlertOptions struct { 85 | Column string `json:"column"` 86 | Op string `json:"op"` 87 | Value int `json:"value"` 88 | CustomSubject string `json:"custom_subject,omitempty"` 89 | CustomBody string `json:"custom_body,omitempty"` 90 | // Deprecated: for backward compatibility 91 | Template string `json:"template,omitempty"` 92 | } 93 | 94 | func (client *Client) CreateAlert(ctx context.Context, input *CreateAlertInput) (*Alert, error) { 95 | res, close, err := client.Post(ctx, "api/alerts", input) 96 | defer close() 97 | 98 | if err != nil { 99 | return nil, err 100 | } 101 | 102 | alert := &Alert{} 103 | 104 | if err := util.UnmarshalBody(res, &alert); err != nil { 105 | return nil, err 106 | } 107 | 108 | return alert, nil 109 | } 110 | 111 | type UpdateAlertInput struct { 112 | Name string `json:"name,omitempty"` 113 | Options *UpdateAlertOptions `json:"options,omitempty"` 114 | QueryId int `json:"query_id,omitempty"` 115 | Rearm int `json:"rearm"` 116 | } 117 | 118 | type UpdateAlertOptions struct { 119 | Column string `json:"column"` 120 | Value int `json:"value"` 121 | Op string `json:"op"` 122 | CustomSubject string `json:"custom_subject,omitempty"` 123 | CustomBody string `json:"custom_body,omitempty"` 124 | // Deprecated: for backward compatibility 125 | Template string `json:"template,omitempty"` 126 | } 127 | 128 | func (client *Client) UpdateAlert(ctx context.Context, id int, input *UpdateAlertInput) (*Alert, error) { 129 | res, close, err := client.Post(ctx, fmt.Sprintf("api/alerts/%d", id), input) 130 | defer close() 131 | 132 | if err != nil { 133 | return nil, err 134 | } 135 | 136 | alert := &Alert{} 137 | 138 | if err := util.UnmarshalBody(res, &alert); err != nil { 139 | return nil, err 140 | } 141 | 142 | return alert, nil 143 | } 144 | 145 | func (client *Client) DeleteAlert(ctx context.Context, id int) error { 146 | _, close, err := client.Delete(ctx, fmt.Sprintf("api/alerts/%d", id)) 147 | defer close() 148 | 149 | if err != nil { 150 | return err 151 | } 152 | 153 | return nil 154 | } 155 | 156 | func (client *Client) ListAlertSubscriptions(ctx context.Context, id int) ([]AlertSubscription, error) { 157 | res, close, err := client.Get(ctx, fmt.Sprintf("api/alerts/%d/subscriptions", id), nil) 158 | defer close() 159 | 160 | if err != nil { 161 | return nil, err 162 | } 163 | 164 | subs := []AlertSubscription{} 165 | 166 | if err := util.UnmarshalBody(res, &subs); err != nil { 167 | return nil, err 168 | } 169 | 170 | return subs, nil 171 | } 172 | 173 | func (client *Client) AddAlertSubscription(ctx context.Context, id int, destinationId int) (*AlertSubscription, error) { 174 | res, close, err := client.Post(ctx, fmt.Sprintf("api/alerts/%d/subscriptions", id), map[string]int{"destination_id": destinationId}) 175 | defer close() 176 | 177 | if err != nil { 178 | return nil, err 179 | } 180 | 181 | sub := &AlertSubscription{} 182 | 183 | if err := util.UnmarshalBody(res, &sub); err != nil { 184 | return nil, err 185 | } 186 | 187 | return sub, nil 188 | } 189 | 190 | func (client *Client) RemoveAlertSubscription(ctx context.Context, id int, subscriptionId int) error { 191 | _, close, err := client.Delete(ctx, fmt.Sprintf("api/alerts/%d/subscriptions/%d", id, subscriptionId)) 192 | defer close() 193 | 194 | if err != nil { 195 | return err 196 | } 197 | 198 | return nil 199 | } 200 | 201 | func (client *Client) MuteAlert(ctx context.Context, id int) error { 202 | _, close, err := client.Post(ctx, fmt.Sprintf("api/alerts/%d/mute", id), nil) 203 | defer close() 204 | 205 | if err != nil { 206 | return err 207 | } 208 | 209 | return nil 210 | } 211 | 212 | func (client *Client) UnmuteAlert(ctx context.Context, id int) error { 213 | _, close, err := client.Delete(ctx, fmt.Sprintf("api/alerts/%d/mute", id)) 214 | defer close() 215 | 216 | if err != nil { 217 | return err 218 | } 219 | 220 | return nil 221 | } 222 | -------------------------------------------------------------------------------- /alert_without_ctx.go: -------------------------------------------------------------------------------- 1 | // Code generated from alert.go using tools/withoutctx.go; DO NOT EDIT. 2 | 3 | package redash 4 | 5 | import "context" 6 | 7 | func (client *ClientWithoutContext) ListAlerts() ([]Alert, error) { 8 | return client.withCtx.ListAlerts(context.Background()) 9 | } 10 | 11 | func (client *ClientWithoutContext) GetAlert(id int) (*Alert, error) { 12 | return client.withCtx.GetAlert(context.Background(), id) 13 | } 14 | 15 | func (client *ClientWithoutContext) CreateAlert(input *CreateAlertInput) (*Alert, error) { 16 | return client.withCtx.CreateAlert(context.Background(), input) 17 | } 18 | 19 | func (client *ClientWithoutContext) UpdateAlert(id int, input *UpdateAlertInput) (*Alert, error) { 20 | return client.withCtx.UpdateAlert(context.Background(), id, input) 21 | } 22 | 23 | func (client *ClientWithoutContext) DeleteAlert(id int) error { 24 | return client.withCtx.DeleteAlert(context.Background(), id) 25 | } 26 | 27 | func (client *ClientWithoutContext) ListAlertSubscriptions(id int) ([]AlertSubscription, error) { 28 | return client.withCtx.ListAlertSubscriptions(context.Background(), id) 29 | } 30 | 31 | func (client *ClientWithoutContext) AddAlertSubscription(id int, destinationId int) (*AlertSubscription, error) { 32 | return client.withCtx.AddAlertSubscription(context.Background(), id, destinationId) 33 | } 34 | 35 | func (client *ClientWithoutContext) RemoveAlertSubscription(id int, subscriptionId int) error { 36 | return client.withCtx.RemoveAlertSubscription(context.Background(), id, subscriptionId) 37 | } 38 | 39 | func (client *ClientWithoutContext) MuteAlert(id int) error { 40 | return client.withCtx.MuteAlert(context.Background(), id) 41 | } 42 | 43 | func (client *ClientWithoutContext) UnmuteAlert(id int) error { 44 | return client.withCtx.UnmuteAlert(context.Background(), id) 45 | } 46 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | package redash 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "net/http/httputil" 11 | "net/url" 12 | "os" 13 | "strconv" 14 | 15 | "github.com/winebarrel/redash-go/v2/internal/util" 16 | ) 17 | 18 | const ( 19 | UserAgent = "redash-go" 20 | ) 21 | 22 | var ( 23 | _debugOut io.Writer = os.Stderr 24 | ) 25 | 26 | type Client struct { 27 | httpCli *http.Client 28 | endpoint string 29 | apiKey string 30 | // Deprecated: Use SetDebug() instead 31 | Debug bool 32 | } 33 | 34 | type ClientWithoutContext struct { 35 | withCtx *Client 36 | } 37 | 38 | func NewClient(endpoint string, apiKey string) (*Client, error) { 39 | return NewClientWithHTTPClient(endpoint, apiKey, nil) 40 | } 41 | 42 | func MustNewClient(endpoint string, apiKey string) *Client { 43 | client, err := NewClient(endpoint, apiKey) 44 | 45 | if err != nil { 46 | panic("MustNewClient(" + strconv.Quote(endpoint) + `, ...): ` + err.Error()) 47 | } 48 | 49 | return client 50 | } 51 | 52 | func MustNewClientWithHTTPClient(endpoint string, apiKey string, httpClient *http.Client) *Client { 53 | client, err := NewClientWithHTTPClient(endpoint, apiKey, httpClient) 54 | 55 | if err != nil { 56 | panic("MustNewClientWithHTTPClient(" + strconv.Quote(endpoint) + `, ...): ` + err.Error()) 57 | } 58 | 59 | return client 60 | } 61 | 62 | func NewClientWithHTTPClient(endpoint string, apiKey string, httpClient *http.Client) (*Client, error) { 63 | _, err := url.Parse(endpoint) 64 | 65 | if err != nil { 66 | return nil, err 67 | } 68 | 69 | if httpClient == nil { 70 | httpClient = http.DefaultClient 71 | } 72 | 73 | client := &Client{ 74 | httpCli: httpClient, 75 | endpoint: endpoint, 76 | apiKey: apiKey, 77 | } 78 | 79 | return client, nil 80 | } 81 | 82 | func (client *Client) SetDebug(debug bool) { 83 | client.Debug = debug 84 | } 85 | 86 | func (client *ClientWithoutContext) SetDebug(debug bool) { 87 | client.withCtx.SetDebug(debug) 88 | } 89 | 90 | func (client *Client) WithoutContext() *ClientWithoutContext { 91 | return &ClientWithoutContext{ 92 | withCtx: client, 93 | } 94 | } 95 | 96 | type responseCloser func() 97 | 98 | func (client *Client) Get(ctx context.Context, path string, params any) (*http.Response, responseCloser, error) { 99 | res, err := client.sendRequest(ctx, http.MethodGet, path, params, nil) 100 | 101 | if err != nil { 102 | return nil, func() {}, fmt.Errorf("GET %s failed: %w", path, err) 103 | } 104 | 105 | return res, func() { util.CloseResponse(res) }, nil 106 | } 107 | 108 | func (client *Client) Post(ctx context.Context, path string, body any) (*http.Response, responseCloser, error) { 109 | res, err := client.sendRequest(ctx, http.MethodPost, path, nil, body) 110 | 111 | if err != nil { 112 | return nil, func() {}, fmt.Errorf("POST %s failed: %w", path, err) 113 | } 114 | 115 | return res, func() { util.CloseResponse(res) }, nil 116 | } 117 | 118 | func (client *Client) Delete(ctx context.Context, path string) (*http.Response, responseCloser, error) { 119 | res, err := client.sendRequest(ctx, http.MethodDelete, path, nil, nil) 120 | 121 | if err != nil { 122 | return nil, func() {}, fmt.Errorf("DELETE %s failed: %w", path, err) 123 | } 124 | 125 | return res, func() { util.CloseResponse(res) }, nil 126 | } 127 | 128 | func (client *Client) sendRequest(ctx context.Context, method string, path string, params any, body any) (*http.Response, error) { 129 | url, err := url.JoinPath(client.endpoint, path) 130 | 131 | if err != nil { 132 | return nil, err 133 | } 134 | 135 | var reader io.Reader 136 | 137 | if body != nil { 138 | rawBody, err := json.Marshal(body) 139 | 140 | if err != nil { 141 | return nil, err 142 | } 143 | 144 | reader = bytes.NewReader(rawBody) 145 | } 146 | 147 | req, err := http.NewRequestWithContext(ctx, method, url, reader) 148 | 149 | if err != nil { 150 | return nil, err 151 | } 152 | 153 | req.Header.Add("Content-Type", "application/json") 154 | req.Header.Add("User-Agent", UserAgent) 155 | req.Header.Set("Authorization", "Key "+client.apiKey) 156 | 157 | if params != nil { 158 | values, err := util.URLValuesFrom(params) 159 | 160 | if err != nil { 161 | return nil, err 162 | } 163 | 164 | req.URL.RawQuery = values.Encode() 165 | } 166 | 167 | if client.Debug { 168 | b, _ := httputil.DumpRequest(req, true) 169 | fmt.Fprintf(_debugOut, "---request begin---\n%s\n---request end---\n", b) 170 | } 171 | 172 | res, err := client.httpCli.Do(req) 173 | 174 | if err != nil { 175 | return nil, err 176 | } 177 | 178 | if client.Debug { 179 | b, _ := httputil.DumpResponse(res, true) 180 | fmt.Fprintf(_debugOut, "---response begin---\n%s\n---response end---\n", b) 181 | } 182 | 183 | if err := util.CheckStatus(res); err != nil { 184 | return nil, err 185 | } 186 | 187 | return res, err 188 | } 189 | -------------------------------------------------------------------------------- /client_private_test.go: -------------------------------------------------------------------------------- 1 | package redash 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "io" 7 | "math" 8 | "net/http" 9 | "testing" 10 | 11 | "github.com/jarcoal/httpmock" 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/require" 14 | ) 15 | 16 | func Test_sendRequest_OK(t *testing.T) { 17 | assert := assert.New(t) 18 | require := require.New(t) 19 | httpmock.Activate() 20 | defer httpmock.DeactivateAndReset() 21 | 22 | httpmock.RegisterResponder(http.MethodGet, "https://redash.example.com/api/queries/1", func(req *http.Request) (*http.Response, error) { 23 | assert.Equal( 24 | http.Header( 25 | http.Header{ 26 | "Authorization": []string{"Key "}, 27 | "Content-Type": []string{"application/json"}, 28 | "User-Agent": []string{"redash-go"}, 29 | }, 30 | ), 31 | req.Header, 32 | ) 33 | assert.Equal("foo=bar", req.URL.Query().Encode()) 34 | return httpmock.NewStringResponse(http.StatusOK, `{"zoo":"baz"}`), nil 35 | }) 36 | 37 | client, _ := NewClient("https://redash.example.com", "") 38 | res, err := client.sendRequest(context.Background(), http.MethodGet, "api/queries/1", map[string]string{"foo": "bar"}, nil) 39 | assert.NoError(err) 40 | assert.Equal("200 OK", res.Status) 41 | require.NotNil(res.Body) 42 | body, _ := io.ReadAll(res.Body) 43 | assert.Equal(`{"zoo":"baz"}`, string(body)) 44 | } 45 | 46 | func Test_sendRequest_Err_JoinPath(t *testing.T) { 47 | assert := assert.New(t) 48 | client, _ := NewClient("https://redash.example.com", "") 49 | client.endpoint = "\b" 50 | _, err := client.sendRequest(context.Background(), http.MethodGet, "api/queries/1", map[string]string{"foo": "bar"}, nil) 51 | assert.ErrorContains(err, "parse \"\\b\": net/url: invalid control character in URL") 52 | } 53 | 54 | func Test_sendRequest_Err_NewRequestWithContext(t *testing.T) { 55 | assert := assert.New(t) 56 | client, _ := NewClient("https://redash.example.com", "") 57 | _, err := client.sendRequest(context.Background(), "あいうえお", "api/queries/1", map[string]string{"foo": "bar"}, nil) 58 | assert.ErrorContains(err, "net/http: invalid method \"あいうえお\"") 59 | } 60 | 61 | func Test_sendRequest_Err_Marshal(t *testing.T) { 62 | assert := assert.New(t) 63 | client, _ := NewClient("https://redash.example.com", "") 64 | _, err := client.sendRequest(context.Background(), http.MethodGet, "api/queries/1", map[string]string{"foo": "bar"}, math.NaN()) 65 | assert.ErrorContains(err, "json: unsupported value: NaN") 66 | } 67 | 68 | func Test_sendRequest_Err_5xx(t *testing.T) { 69 | assert := assert.New(t) 70 | httpmock.Activate() 71 | defer httpmock.DeactivateAndReset() 72 | 73 | httpmock.RegisterResponder(http.MethodGet, "https://redash.example.com/api/queries/1", func(req *http.Request) (*http.Response, error) { 74 | return httpmock.NewStringResponse(http.StatusServiceUnavailable, "error"), nil 75 | }) 76 | 77 | client, _ := NewClient("https://redash.example.com", "") 78 | _, err := client.sendRequest(context.Background(), http.MethodGet, "api/queries/1", map[string]string{"foo": "bar"}, nil) 79 | assert.ErrorContains(err, "HTTP status code not OK: 503 Service Unavailable\nerror") 80 | } 81 | 82 | func Test_sendRequest_Err_params(t *testing.T) { 83 | assert := assert.New(t) 84 | client, _ := NewClient("https://redash.example.com", "") 85 | _, err := client.sendRequest(context.Background(), http.MethodGet, "api/queries/1", "bad params", nil) 86 | assert.ErrorContains(err, "query: Values() expects struct input. Got string") 87 | } 88 | 89 | func Test_sendRequest_Err_HTTPRequest(t *testing.T) { 90 | assert := assert.New(t) 91 | client, _ := NewClient("https://redash.example.com", "") 92 | client.endpoint = "x" 93 | _, err := client.sendRequest(context.Background(), http.MethodGet, "api/queries/1", map[string]string{"foo": "bar"}, nil) 94 | assert.ErrorContains(err, `Get "x/api/queries/1?foo=bar": unsupported protocol scheme ""`) 95 | } 96 | 97 | func Test_sendRequest_Debug(t *testing.T) { 98 | assert := assert.New(t) 99 | require := require.New(t) 100 | 101 | origDebugOut := _debugOut 102 | var buf bytes.Buffer 103 | _debugOut = &buf 104 | defer func() { _debugOut = origDebugOut }() 105 | 106 | httpmock.Activate() 107 | defer httpmock.DeactivateAndReset() 108 | 109 | httpmock.RegisterResponder(http.MethodGet, "https://redash.example.com/api/queries/1", func(req *http.Request) (*http.Response, error) { 110 | return httpmock.NewStringResponse(http.StatusOK, `{"zoo":"baz"}`), nil 111 | }) 112 | 113 | client, _ := NewClient("https://redash.example.com", "") 114 | client.SetDebug(true) 115 | res, err := client.sendRequest(context.Background(), http.MethodGet, "api/queries/1", map[string]string{"foo": "bar"}, nil) 116 | require.NoError(err) 117 | assert.Equal("200 OK", res.Status) 118 | assert.Equal("---request begin---\nGET /api/queries/1?foo=bar HTTP/1.1\r\nHost: redash.example.com\r\nAuthorization: Key \r\nContent-Type: application/json\r\nUser-Agent: redash-go\r\n\r\n\n---request end---\n---response begin---\nHTTP/0.0 200 OK\r\n\r\n{\"zoo\":\"baz\"}\n---response end---\n", buf.String()) 119 | } 120 | -------------------------------------------------------------------------------- /client_test.go: -------------------------------------------------------------------------------- 1 | package redash_test 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net/http" 7 | "testing" 8 | 9 | "github.com/jarcoal/httpmock" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | "github.com/winebarrel/redash-go/v2" 13 | ) 14 | 15 | func Test_NewClient_OK(t *testing.T) { 16 | assert := assert.New(t) 17 | _, err := redash.NewClient("https://redash.example.com", testRedashAPIKey) 18 | assert.NoError(err) 19 | } 20 | 21 | func Test_NewClient_Err(t *testing.T) { 22 | assert := assert.New(t) 23 | _, err := redash.NewClient(":redash.example.com", testRedashAPIKey) 24 | assert.ErrorContains(err, `parse ":redash.example.com": missing protocol scheme`) 25 | } 26 | 27 | func Test_MustNewClient_OK(t *testing.T) { 28 | assert := assert.New(t) 29 | client := redash.MustNewClient("https://redash.example.com", testRedashAPIKey) 30 | assert.NotNil(client) 31 | } 32 | 33 | func Test_MustNewClient_Err(t *testing.T) { 34 | assert := assert.New(t) 35 | 36 | defer func() { 37 | err := recover() 38 | assert.Contains(err, `MustNewClient(":redash.example.com", ...):`) 39 | }() 40 | 41 | client := redash.MustNewClient(":redash.example.com", testRedashAPIKey) 42 | assert.NotNil(client) 43 | } 44 | 45 | func Test_MustNewClientWithHTTPClient_OK(t *testing.T) { 46 | assert := assert.New(t) 47 | client := redash.MustNewClientWithHTTPClient("https://redash.example.com", testRedashAPIKey, &http.Client{}) 48 | assert.NotNil(client) 49 | } 50 | 51 | func Test_MustNewClientWithHTTPClient_Err(t *testing.T) { 52 | assert := assert.New(t) 53 | 54 | defer func() { 55 | err := recover() 56 | assert.Contains(err, `MustNewClientWithHTTPClient(":redash.example.com", ...):`) 57 | }() 58 | 59 | client := redash.MustNewClientWithHTTPClient(":redash.example.com", testRedashAPIKey, &http.Client{}) 60 | assert.NotNil(client) 61 | } 62 | 63 | func Test_Get_OK(t *testing.T) { 64 | assert := assert.New(t) 65 | require := require.New(t) 66 | httpmock.Activate() 67 | defer httpmock.DeactivateAndReset() 68 | 69 | httpmock.RegisterResponder(http.MethodGet, "https://redash.example.com/api/queries/1", func(req *http.Request) (*http.Response, error) { 70 | assert.Equal( 71 | http.Header( 72 | http.Header{ 73 | "Authorization": []string{"Key " + testRedashAPIKey}, 74 | "Content-Type": []string{"application/json"}, 75 | "User-Agent": []string{"redash-go"}, 76 | }, 77 | ), 78 | req.Header, 79 | ) 80 | assert.Equal("foo=bar", req.URL.Query().Encode()) 81 | return httpmock.NewStringResponse(http.StatusOK, `{"zoo":"baz"}`), nil 82 | }) 83 | 84 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 85 | res, close, err := client.Get(context.Background(), "api/queries/1", map[string]string{"foo": "bar"}) 86 | defer close() 87 | assert.NoError(err) 88 | assert.Equal("200 OK", res.Status) 89 | require.NotNil(res.Body) 90 | body, _ := io.ReadAll(res.Body) 91 | assert.Equal(`{"zoo":"baz"}`, string(body)) 92 | } 93 | 94 | type testRoundTripper struct { 95 | callback func(req *http.Request) 96 | } 97 | 98 | func (t *testRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { 99 | t.callback(req) 100 | return http.DefaultTransport.RoundTrip(req) 101 | } 102 | 103 | func Test_Get_WithTransport(t *testing.T) { 104 | assert := assert.New(t) 105 | require := require.New(t) 106 | httpmock.Activate() 107 | defer httpmock.DeactivateAndReset() 108 | 109 | httpmock.RegisterResponder(http.MethodGet, "https://redash.example.com/api/queries/1", func(req *http.Request) (*http.Response, error) { 110 | assert.Equal( 111 | http.Header( 112 | http.Header{ 113 | "Authorization": []string{"Key " + testRedashAPIKey}, 114 | "Content-Type": []string{"application/json"}, 115 | "Foo": []string{"bar"}, 116 | "User-Agent": []string{"my-user-agent"}, 117 | }, 118 | ), 119 | req.Header, 120 | ) 121 | assert.Equal("foo=bar", req.URL.Query().Encode()) 122 | return httpmock.NewStringResponse(http.StatusOK, `{"zoo":"baz"}`), nil 123 | }) 124 | 125 | client, _ := redash.NewClientWithHTTPClient("https://redash.example.com", testRedashAPIKey, &http.Client{ 126 | Transport: &testRoundTripper{ 127 | func(req *http.Request) { 128 | req.Header.Set("foo", "bar") 129 | req.Header.Set("user-agent", "my-user-agent") 130 | }, 131 | }, 132 | }) 133 | res, close, err := client.Get(context.Background(), "api/queries/1", map[string]string{"foo": "bar"}) 134 | defer close() 135 | assert.NoError(err) 136 | assert.Equal("200 OK", res.Status) 137 | require.NotNil(res.Body) 138 | body, _ := io.ReadAll(res.Body) 139 | assert.Equal(`{"zoo":"baz"}`, string(body)) 140 | } 141 | 142 | func Test_Get_Err(t *testing.T) { 143 | assert := assert.New(t) 144 | httpmock.Activate() 145 | defer httpmock.DeactivateAndReset() 146 | 147 | httpmock.RegisterResponder(http.MethodGet, "https://redash.example.com/api/queries/1", func(req *http.Request) (*http.Response, error) { 148 | return httpmock.NewStringResponse(http.StatusNotFound, ``), nil 149 | }) 150 | 151 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 152 | _, close, err := client.Get(context.Background(), "api/queries/1", map[string]string{"foo": "bar"}) 153 | defer close() 154 | assert.ErrorContains(err, "GET api/queries/1 failed: HTTP status code not OK: 404 Not Found") 155 | } 156 | 157 | func Test_Post_OK(t *testing.T) { 158 | assert := assert.New(t) 159 | require := require.New(t) 160 | httpmock.Activate() 161 | defer httpmock.DeactivateAndReset() 162 | 163 | httpmock.RegisterResponder(http.MethodPost, "https://redash.example.com/api/queries/1", func(req *http.Request) (*http.Response, error) { 164 | assert.Equal( 165 | http.Header( 166 | http.Header{ 167 | "Authorization": []string{"Key " + testRedashAPIKey}, 168 | "Content-Type": []string{"application/json"}, 169 | "User-Agent": []string{"redash-go"}, 170 | }, 171 | ), 172 | req.Header, 173 | ) 174 | if req.Body == nil { 175 | assert.FailNow("req.Body is nil") 176 | } 177 | body, _ := io.ReadAll(req.Body) 178 | assert.Equal(`{"foo":"bar"}`, string(body)) 179 | return httpmock.NewStringResponse(http.StatusOK, `{"zoo":"baz"}`), nil 180 | }) 181 | 182 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 183 | res, close, err := client.Post(context.Background(), "api/queries/1", map[string]string{"foo": "bar"}) 184 | defer close() 185 | assert.NoError(err) 186 | assert.Equal("200 OK", res.Status) 187 | require.NotNil(res.Body) 188 | body, _ := io.ReadAll(res.Body) 189 | assert.Equal(`{"zoo":"baz"}`, string(body)) 190 | } 191 | 192 | func Test_Post_Err(t *testing.T) { 193 | assert := assert.New(t) 194 | httpmock.Activate() 195 | defer httpmock.DeactivateAndReset() 196 | 197 | httpmock.RegisterResponder(http.MethodPost, "https://redash.example.com/api/queries/1", func(req *http.Request) (*http.Response, error) { 198 | return httpmock.NewStringResponse(http.StatusNotFound, ``), nil 199 | }) 200 | 201 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 202 | _, close, err := client.Post(context.Background(), "api/queries/1", map[string]string{"foo": "bar"}) 203 | defer close() 204 | assert.ErrorContains(err, "POST api/queries/1 failed: HTTP status code not OK: 404 Not Found") 205 | } 206 | 207 | func Test_Delete_OK(t *testing.T) { 208 | assert := assert.New(t) 209 | httpmock.Activate() 210 | defer httpmock.DeactivateAndReset() 211 | 212 | httpmock.RegisterResponder(http.MethodDelete, "https://redash.example.com/api/queries/1", func(req *http.Request) (*http.Response, error) { 213 | assert.Equal( 214 | http.Header( 215 | http.Header{ 216 | "Authorization": []string{"Key " + testRedashAPIKey}, 217 | "Content-Type": []string{"application/json"}, 218 | "User-Agent": []string{"redash-go"}, 219 | }, 220 | ), 221 | req.Header, 222 | ) 223 | return httpmock.NewStringResponse(http.StatusOK, ``), nil 224 | }) 225 | 226 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 227 | res, close, err := client.Delete(context.Background(), "api/queries/1") 228 | defer close() 229 | assert.NoError(err) 230 | assert.Equal("200 OK", res.Status) 231 | } 232 | 233 | func Test_Delete_Err(t *testing.T) { 234 | assert := assert.New(t) 235 | httpmock.Activate() 236 | defer httpmock.DeactivateAndReset() 237 | 238 | httpmock.RegisterResponder(http.MethodDelete, "https://redash.example.com/api/queries/1", func(req *http.Request) (*http.Response, error) { 239 | return httpmock.NewStringResponse(http.StatusNotFound, ``), nil 240 | }) 241 | 242 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 243 | _, close, err := client.Delete(context.Background(), "api/queries/1") 244 | defer close() 245 | assert.ErrorContains(err, "DELETE api/queries/1 failed: HTTP status code not OK: 404 Not Found") 246 | } 247 | 248 | func Test_SetDebug(t *testing.T) { 249 | assert := assert.New(t) 250 | client := redash.MustNewClient("https://redash.example.com", testRedashAPIKey) 251 | 252 | assert.False(client.Debug) 253 | client.SetDebug(true) 254 | assert.True(client.Debug) 255 | client.SetDebug(false) 256 | assert.False(client.Debug) 257 | 258 | withoutCtx := client.WithoutContext() 259 | assert.False(client.Debug) 260 | withoutCtx.SetDebug(true) 261 | assert.True(client.Debug) 262 | withoutCtx.SetDebug(false) 263 | assert.False(client.Debug) 264 | } 265 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | ignore: 2 | - ".*_without_ctx\\.go" 3 | - "tools" 4 | coverage: 5 | status: 6 | project: 7 | default: 8 | informational: true 9 | patch: 10 | default: 11 | informational: true 12 | -------------------------------------------------------------------------------- /compose.yaml: -------------------------------------------------------------------------------- 1 | x-redash-environment: &redash-environment 2 | REDASH_LOG_LEVEL: "INFO" 3 | REDASH_REDIS_URL: "redis://redis:6379/0" 4 | REDASH_DATABASE_URL: "postgresql://postgres@postgres/postgres" 5 | REDASH_RATELIMIT_ENABLED: "false" 6 | REDASH_MAIL_DEFAULT_SENDER: "redash@example.com" 7 | REDASH_MAIL_SERVER: "email" 8 | REDASH_MAIL_PORT: 1025 9 | REDASH_ENFORCE_CSRF: "true" 10 | REDASH_GUNICORN_TIMEOUT: 60 11 | REDASH_COOKIE_SECRET: secret 12 | REDASH_HOST: "http://localhost:5001" 13 | # REDASH_FEATURE_SHOW_PERMISSIONS_CONTROL: true 14 | 15 | services: 16 | server: 17 | image: redash/redash:25.8.0 18 | # command: dev_server 19 | depends_on: 20 | postgres: 21 | condition: service_healthy 22 | redis: 23 | condition: service_started 24 | ports: 25 | - "5001:5000" 26 | - "5678:5678" 27 | environment: 28 | <<: *redash-environment 29 | PYTHONUNBUFFERED: 0 30 | scheduler: 31 | image: redash/redash:25.8.0 32 | command: scheduler 33 | # command: dev_scheduler 34 | depends_on: 35 | - server 36 | environment: 37 | <<: *redash-environment 38 | worker: 39 | image: redash/redash:25.8.0 40 | command: worker 41 | # command: dev_worker 42 | depends_on: 43 | - server 44 | environment: 45 | <<: *redash-environment 46 | PYTHONUNBUFFERED: 0 47 | redis: 48 | image: redis:8-alpine 49 | restart: unless-stopped 50 | postgres: 51 | image: pgautoupgrade/pgautoupgrade:16-alpine 52 | ports: 53 | - "15432:5432" 54 | command: "postgres -c fsync=off -c full_page_writes=off -c synchronous_commit=OFF" 55 | restart: unless-stopped 56 | environment: 57 | POSTGRES_HOST_AUTH_METHOD: "trust" 58 | healthcheck: 59 | test: ["CMD-SHELL", "pg_isready -U postgres"] 60 | interval: 1s 61 | timeout: 1s 62 | retries: 10 63 | email: 64 | image: maildev/maildev 65 | ports: 66 | - "10081:1080" 67 | environment: 68 | MAILDEV_WEB_PORT: 1080 69 | restart: unless-stopped 70 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | //go:generate go run tools/withoutctx.go 2 | package redash 3 | 4 | import ( 5 | "context" 6 | 7 | "github.com/winebarrel/redash-go/v2/internal/util" 8 | ) 9 | 10 | type Config struct { 11 | ClientConfig ClientConfig `json:"client_config"` 12 | OrgSlug string `json:"org_slug"` 13 | } 14 | 15 | type ClientConfig struct { 16 | AllowCustomJSVisualizations bool `json:"allowCustomJSVisualizations"` 17 | AllowScriptsInUserInput bool `json:"allowScriptsInUserInput"` 18 | AutoPublishNamedQueries bool `json:"autoPublishNamedQueries"` 19 | BasePath string `json:"basePath"` 20 | DashboardRefreshIntervals []int `json:"dashboardRefreshIntervals"` 21 | DateFormat string `json:"dateFormat"` 22 | DateFormatList []string `json:"dateFormatList"` 23 | DateTimeFormat string `json:"dateTimeFormat"` 24 | ExtendedAlertOptions bool `json:"extendedAlertOptions"` 25 | FloatFormat string `json:"floatFormat"` 26 | GoogleLoginEnabled bool `json:"googleLoginEnabled"` 27 | IntegerFormat string `json:"integerFormat"` 28 | MailSettingsMissing bool `json:"mailSettingsMissing"` 29 | NewVersionAvailable bool `json:"newVersionAvailable"` 30 | PageSize int `json:"pageSize"` 31 | PageSizeOptions []int `json:"pageSizeOptions"` 32 | QueryRefreshIntervals []int `json:"queryRefreshIntervals"` 33 | ShowBeaconConsentMessage bool `json:"showBeaconConsentMessage"` 34 | ShowPermissionsControl bool `json:"showPermissionsControl"` 35 | TableCellMaxJSONSize int `json:"tableCellMaxJSONSize"` 36 | TimeFormatList []string `json:"timeFormatList"` 37 | Version string `json:"version"` 38 | } 39 | 40 | func (client *Client) GetConfig(ctx context.Context) (*Config, error) { 41 | res, close, err := client.Get(ctx, "api/config", nil) 42 | defer close() 43 | 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | config := &Config{} 49 | 50 | if err := util.UnmarshalBody(res, &config); err != nil { 51 | return nil, err 52 | } 53 | 54 | return config, nil 55 | } 56 | -------------------------------------------------------------------------------- /config_test.go: -------------------------------------------------------------------------------- 1 | package redash_test 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/jarcoal/httpmock" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | "github.com/winebarrel/redash-go/v2" 13 | ) 14 | 15 | func Test_GetConfig_OK(t *testing.T) { 16 | assert := assert.New(t) 17 | httpmock.Activate() 18 | defer httpmock.DeactivateAndReset() 19 | 20 | httpmock.RegisterResponder(http.MethodGet, "https://redash.example.com/api/config", func(req *http.Request) (*http.Response, error) { 21 | assert.Equal( 22 | http.Header( 23 | http.Header{ 24 | "Authorization": []string{"Key " + testRedashAPIKey}, 25 | "Content-Type": []string{"application/json"}, 26 | "User-Agent": []string{"redash-go"}, 27 | }, 28 | ), 29 | req.Header, 30 | ) 31 | return httpmock.NewStringResponse(http.StatusOK, ` 32 | { 33 | "client_config": { 34 | "allowCustomJSVisualizations": false, 35 | "allowScriptsInUserInput": false, 36 | "autoPublishNamedQueries": true, 37 | "basePath": "http://localhost:5000/", 38 | "dashboardRefreshIntervals": [ 39 | 60, 40 | 300, 41 | 600, 42 | 1800, 43 | 3600, 44 | 43200, 45 | 86400 46 | ], 47 | "dateFormat": "DD/MM/YY", 48 | "dateFormatList": [ 49 | "DD/MM/YY", 50 | "YYYY-MM-DD", 51 | "MM/DD/YY" 52 | ], 53 | "dateTimeFormat": "DD/MM/YY HH:mm", 54 | "extendedAlertOptions": false, 55 | "floatFormat": "0,0.00", 56 | "googleLoginEnabled": false, 57 | "integerFormat": "0,0", 58 | "mailSettingsMissing": false, 59 | "newVersionAvailable": false, 60 | "pageSize": 20, 61 | "pageSizeOptions": [ 62 | 5, 63 | 10, 64 | 20, 65 | 50, 66 | 100 67 | ], 68 | "queryRefreshIntervals": [ 69 | 60, 70 | 300, 71 | 600, 72 | 900, 73 | 1800, 74 | 3600, 75 | 7200, 76 | 10800, 77 | 14400, 78 | 18000, 79 | 21600, 80 | 25200, 81 | 28800, 82 | 32400, 83 | 36000, 84 | 39600, 85 | 43200, 86 | 86400, 87 | 604800, 88 | 1209600, 89 | 2592000 90 | ], 91 | "showBeaconConsentMessage": true, 92 | "showPermissionsControl": false, 93 | "tableCellMaxJSONSize": 50000, 94 | "timeFormatList": [ 95 | "HH:mm:ss", 96 | "HH:mm", 97 | "HH:mm:ss.SSS" 98 | ], 99 | "version": "8.0.0+b32245" 100 | }, 101 | "org_slug": "default" 102 | } 103 | `), nil 104 | }) 105 | 106 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 107 | res, err := client.GetConfig(context.Background()) 108 | assert.NoError(err) 109 | assert.Equal(&redash.Config{ 110 | ClientConfig: redash.ClientConfig{ 111 | AllowCustomJSVisualizations: false, 112 | AllowScriptsInUserInput: false, 113 | AutoPublishNamedQueries: true, 114 | BasePath: "http://localhost:5000/", 115 | DashboardRefreshIntervals: []int{ 116 | 60, 117 | 300, 118 | 600, 119 | 1800, 120 | 3600, 121 | 43200, 122 | 86400, 123 | }, 124 | DateFormat: "DD/MM/YY", 125 | DateFormatList: []string{ 126 | "DD/MM/YY", 127 | "YYYY-MM-DD", 128 | "MM/DD/YY", 129 | }, 130 | DateTimeFormat: "DD/MM/YY HH:mm", 131 | ExtendedAlertOptions: false, 132 | FloatFormat: "0,0.00", 133 | GoogleLoginEnabled: false, 134 | IntegerFormat: "0,0", 135 | MailSettingsMissing: false, 136 | NewVersionAvailable: false, 137 | PageSize: 20, 138 | PageSizeOptions: []int{ 139 | 5, 140 | 10, 141 | 20, 142 | 50, 143 | 100, 144 | }, 145 | QueryRefreshIntervals: []int{ 146 | 60, 147 | 300, 148 | 600, 149 | 900, 150 | 1800, 151 | 3600, 152 | 7200, 153 | 10800, 154 | 14400, 155 | 18000, 156 | 21600, 157 | 25200, 158 | 28800, 159 | 32400, 160 | 36000, 161 | 39600, 162 | 43200, 163 | 86400, 164 | 604800, 165 | 1209600, 166 | 2592000, 167 | }, 168 | ShowBeaconConsentMessage: true, 169 | ShowPermissionsControl: false, 170 | TableCellMaxJSONSize: 50000, 171 | TimeFormatList: []string{ 172 | "HH:mm:ss", 173 | "HH:mm", 174 | "HH:mm:ss.SSS", 175 | }, 176 | Version: "8.0.0+b32245", 177 | }, 178 | OrgSlug: "default", 179 | }, res) 180 | } 181 | 182 | func Test_GetConfig_Err_5xx(t *testing.T) { 183 | assert := assert.New(t) 184 | httpmock.Activate() 185 | defer httpmock.DeactivateAndReset() 186 | 187 | httpmock.RegisterResponder(http.MethodGet, "https://redash.example.com/api/config", func(req *http.Request) (*http.Response, error) { 188 | return httpmock.NewStringResponse(http.StatusServiceUnavailable, "error"), nil 189 | }) 190 | 191 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 192 | _, err := client.GetConfig(context.Background()) 193 | assert.ErrorContains(err, "GET api/config failed: HTTP status code not OK: 503 Service Unavailable\nerror") 194 | } 195 | 196 | func Test_GetConfig_IOErr(t *testing.T) { 197 | assert := assert.New(t) 198 | httpmock.Activate() 199 | defer httpmock.DeactivateAndReset() 200 | 201 | httpmock.RegisterResponder(http.MethodGet, "https://redash.example.com/api/config", func(req *http.Request) (*http.Response, error) { 202 | return testIOErrResp, nil 203 | }) 204 | 205 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 206 | _, err := client.GetConfig(context.Background()) 207 | assert.ErrorContains(err, "read response body failed: IO error") 208 | } 209 | 210 | func Test_Config_Acc(t *testing.T) { 211 | if !testAcc { 212 | t.Skip() 213 | } 214 | 215 | assert := assert.New(t) 216 | require := require.New(t) 217 | 218 | // NOTE: No authentication required 219 | client, _ := redash.NewClient(testRedashEndpoint, "") 220 | config, err := client.GetConfig(context.Background()) 221 | require.NoError(err) 222 | assert.Equal("default", config.OrgSlug) 223 | assert.Equal(50000, config.ClientConfig.TableCellMaxJSONSize) 224 | assert.True(strings.HasPrefix(config.ClientConfig.BasePath, testRedashEndpoint)) 225 | } 226 | -------------------------------------------------------------------------------- /config_without_ctx.go: -------------------------------------------------------------------------------- 1 | // Code generated from config.go using tools/withoutctx.go; DO NOT EDIT. 2 | 3 | package redash 4 | 5 | import "context" 6 | 7 | func (client *ClientWithoutContext) GetConfig() (*Config, error) { 8 | return client.withCtx.GetConfig(context.Background()) 9 | } 10 | -------------------------------------------------------------------------------- /dashboard.go: -------------------------------------------------------------------------------- 1 | //go:generate go run tools/withoutctx.go 2 | package redash 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | "time" 8 | 9 | "github.com/winebarrel/redash-go/v2/internal/util" 10 | ) 11 | 12 | type DashboardPage struct { 13 | Count int `json:"count"` 14 | Page int `json:"page"` 15 | PageSize int `json:"page_size"` 16 | Results []Dashboard `json:"results"` 17 | } 18 | 19 | type Dashboard struct { 20 | CanEdit bool `json:"can_edit"` 21 | CreatedAt time.Time `json:"created_at"` 22 | DashboardFiltersEnabled bool `json:"dashboard_filters_enabled"` 23 | ID int `json:"id"` 24 | IsArchived bool `json:"is_archived"` 25 | IsDraft bool `json:"is_draft"` 26 | IsFavorite bool `json:"is_favorite"` 27 | Layout any `json:"layout"` 28 | Name string `json:"name"` 29 | Slug string `json:"slug"` 30 | Tags []string `json:"tags"` 31 | UpdatedAt time.Time `json:"updated_at"` 32 | User User `json:"user"` 33 | UserID int `json:"user_id"` 34 | Version int `json:"version"` 35 | Widgets []Widget `json:"widgets"` 36 | } 37 | 38 | type ListDashboardsInput struct { 39 | OnlyFavorites bool `url:"only_favorites,omitempty"` 40 | Page int `url:"page,omitempty"` 41 | PageSize int `url:"page_size,omitempty"` 42 | Q string `url:"q,omitempty"` 43 | } 44 | 45 | func (client *Client) ListDashboards(ctx context.Context, input *ListDashboardsInput) (*DashboardPage, error) { 46 | res, close, err := client.Get(ctx, "api/dashboards", input) 47 | defer close() 48 | 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | page := &DashboardPage{} 54 | 55 | if err := util.UnmarshalBody(res, &page); err != nil { 56 | return nil, err 57 | } 58 | 59 | return page, nil 60 | } 61 | 62 | func (client *Client) GetDashboard(ctx context.Context, id int) (*Dashboard, error) { 63 | res, close, err := client.Get(ctx, fmt.Sprintf("api/dashboards/%d", id), nil) 64 | defer close() 65 | 66 | if err != nil { 67 | return nil, err 68 | } 69 | 70 | dashboard := &Dashboard{} 71 | 72 | if err := util.UnmarshalBody(res, &dashboard); err != nil { 73 | return nil, err 74 | } 75 | 76 | return dashboard, nil 77 | } 78 | 79 | func (client *Client) CreateFavoriteDashboard(ctx context.Context, id int) error { 80 | _, close, err := client.Post(ctx, fmt.Sprintf("api/dashboards/%d/favorite", id), nil) 81 | defer close() 82 | 83 | if err != nil { 84 | return err 85 | } 86 | 87 | return nil 88 | } 89 | 90 | type CreateDashboardInput struct { 91 | Name string `json:"name"` 92 | } 93 | 94 | func (client *Client) CreateDashboard(ctx context.Context, input *CreateDashboardInput) (*Dashboard, error) { 95 | res, close, err := client.Post(ctx, "api/dashboards", input) 96 | defer close() 97 | 98 | if err != nil { 99 | return nil, err 100 | } 101 | 102 | dashboard := &Dashboard{} 103 | 104 | if err := util.UnmarshalBody(res, &dashboard); err != nil { 105 | return nil, err 106 | } 107 | 108 | return dashboard, nil 109 | } 110 | 111 | type UpdateDashboardInput struct { 112 | DashboardFiltersEnabled bool `json:"dashboard_filters_enabled,omitempty"` 113 | IsArchived bool `json:"is_archived,omitempty"` 114 | IsDraft bool `json:"is_draft,omitempty"` 115 | Layout []any `json:"layout,omitempty"` 116 | Name string `json:"name,omitempty"` 117 | Options any `json:"options,omitempty"` 118 | Tags *[]string `json:"tags,omitempty"` 119 | Version int `json:"version,omitempty"` 120 | } 121 | 122 | func (client *Client) UpdateDashboard(ctx context.Context, id int, input *UpdateDashboardInput) (*Dashboard, error) { 123 | res, close, err := client.Post(ctx, fmt.Sprintf("api/dashboards/%d", id), input) 124 | defer close() 125 | 126 | if err != nil { 127 | return nil, err 128 | } 129 | 130 | dashboard := &Dashboard{} 131 | 132 | if err := util.UnmarshalBody(res, &dashboard); err != nil { 133 | return nil, err 134 | } 135 | 136 | return dashboard, nil 137 | } 138 | 139 | func (client *Client) ArchiveDashboard(ctx context.Context, id int) error { 140 | _, close, err := client.Delete(ctx, fmt.Sprintf("api/dashboards/%d", id)) 141 | defer close() 142 | 143 | if err != nil { 144 | return err 145 | } 146 | 147 | return nil 148 | } 149 | 150 | type DashboardTags struct { 151 | Tags []DashboardTagsTag `json:"tags"` 152 | } 153 | 154 | type DashboardTagsTag struct { 155 | Count int `json:"count"` 156 | Name string `json:"name"` 157 | } 158 | 159 | func (client *Client) GetDashboardTags(ctx context.Context) (*DashboardTags, error) { 160 | res, close, err := client.Get(ctx, "api/dashboards/tags", nil) 161 | defer close() 162 | 163 | if err != nil { 164 | return nil, err 165 | } 166 | 167 | tags := &DashboardTags{} 168 | 169 | if err := util.UnmarshalBody(res, &tags); err != nil { 170 | return nil, err 171 | } 172 | 173 | return tags, nil 174 | } 175 | 176 | type ListMyDashboardsInput struct { 177 | Page int `url:"page,omitempty"` 178 | PageSize int `url:"page_size,omitempty"` 179 | Q string `url:"q,omitempty"` 180 | } 181 | 182 | func (client *Client) ListMyDashboards(ctx context.Context, input *ListMyDashboardsInput) (*DashboardPage, error) { 183 | res, close, err := client.Get(ctx, "api/dashboards/my", input) 184 | defer close() 185 | 186 | if err != nil { 187 | return nil, err 188 | } 189 | 190 | page := &DashboardPage{} 191 | 192 | if err := util.UnmarshalBody(res, &page); err != nil { 193 | return nil, err 194 | } 195 | 196 | return page, nil 197 | } 198 | 199 | type ListFavoriteDashboardsInput struct { 200 | Page int `url:"page,omitempty"` 201 | PageSize int `url:"page_size,omitempty"` 202 | Q string `url:"q,omitempty"` 203 | } 204 | 205 | func (client *Client) ListFavoriteDashboards(ctx context.Context, input *ListFavoriteDashboardsInput) (*DashboardPage, error) { 206 | res, close, err := client.Get(ctx, "api/dashboards/favorites", input) 207 | defer close() 208 | 209 | if err != nil { 210 | return nil, err 211 | } 212 | 213 | page := &DashboardPage{} 214 | 215 | if err := util.UnmarshalBody(res, &page); err != nil { 216 | return nil, err 217 | } 218 | 219 | return page, nil 220 | } 221 | 222 | type ShareDashboardOutput struct { 223 | APIKey string `json:"api_key"` 224 | PublicURL string `json:"public_url"` 225 | } 226 | 227 | func (client *Client) ShareDashboard(ctx context.Context, id int) (*ShareDashboardOutput, error) { 228 | res, close, err := client.Post(ctx, fmt.Sprintf("api/dashboards/%d/share", id), nil) 229 | defer close() 230 | 231 | if err != nil { 232 | return nil, err 233 | } 234 | 235 | output := &ShareDashboardOutput{} 236 | 237 | if err := util.UnmarshalBody(res, &output); err != nil { 238 | return nil, err 239 | } 240 | 241 | return output, nil 242 | } 243 | 244 | func (client *Client) UnshareDashboard(ctx context.Context, id int) error { 245 | _, close, err := client.Delete(ctx, fmt.Sprintf("api/dashboards/%d/share", id)) 246 | defer close() 247 | 248 | if err != nil { 249 | return err 250 | } 251 | 252 | return nil 253 | } 254 | -------------------------------------------------------------------------------- /dashboard_without_ctx.go: -------------------------------------------------------------------------------- 1 | // Code generated from dashboard.go using tools/withoutctx.go; DO NOT EDIT. 2 | 3 | package redash 4 | 5 | import "context" 6 | 7 | func (client *ClientWithoutContext) ListDashboards(input *ListDashboardsInput) (*DashboardPage, error) { 8 | return client.withCtx.ListDashboards(context.Background(), input) 9 | } 10 | 11 | func (client *ClientWithoutContext) GetDashboard(id int) (*Dashboard, error) { 12 | return client.withCtx.GetDashboard(context.Background(), id) 13 | } 14 | 15 | func (client *ClientWithoutContext) CreateFavoriteDashboard(id int) error { 16 | return client.withCtx.CreateFavoriteDashboard(context.Background(), id) 17 | } 18 | 19 | func (client *ClientWithoutContext) CreateDashboard(input *CreateDashboardInput) (*Dashboard, error) { 20 | return client.withCtx.CreateDashboard(context.Background(), input) 21 | } 22 | 23 | func (client *ClientWithoutContext) UpdateDashboard(id int, input *UpdateDashboardInput) (*Dashboard, error) { 24 | return client.withCtx.UpdateDashboard(context.Background(), id, input) 25 | } 26 | 27 | func (client *ClientWithoutContext) ArchiveDashboard(id int) error { 28 | return client.withCtx.ArchiveDashboard(context.Background(), id) 29 | } 30 | 31 | func (client *ClientWithoutContext) GetDashboardTags() (*DashboardTags, error) { 32 | return client.withCtx.GetDashboardTags(context.Background()) 33 | } 34 | 35 | func (client *ClientWithoutContext) ListMyDashboards(input *ListMyDashboardsInput) (*DashboardPage, error) { 36 | return client.withCtx.ListMyDashboards(context.Background(), input) 37 | } 38 | 39 | func (client *ClientWithoutContext) ListFavoriteDashboards(input *ListFavoriteDashboardsInput) (*DashboardPage, error) { 40 | return client.withCtx.ListFavoriteDashboards(context.Background(), input) 41 | } 42 | 43 | func (client *ClientWithoutContext) ShareDashboard(id int) (*ShareDashboardOutput, error) { 44 | return client.withCtx.ShareDashboard(context.Background(), id) 45 | } 46 | 47 | func (client *ClientWithoutContext) UnshareDashboard(id int) error { 48 | return client.withCtx.UnshareDashboard(context.Background(), id) 49 | } 50 | -------------------------------------------------------------------------------- /data_source.go: -------------------------------------------------------------------------------- 1 | //go:generate go run tools/withoutctx.go 2 | package redash 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | 8 | "github.com/winebarrel/redash-go/v2/internal/util" 9 | ) 10 | 11 | type DataSource struct { 12 | Groups map[int]bool `json:"groups"` 13 | ID int `json:"id"` 14 | Name string `json:"name"` 15 | Options map[string]any `json:"options"` 16 | Paused int `json:"paused"` 17 | PauseReason string `json:"pause_reason"` 18 | QueueName string `json:"queue_name"` 19 | ScheduledQueueName string `json:"scheduled_queue_name"` 20 | Syntax string `json:"syntax"` 21 | Type string `json:"type"` 22 | ViewOnly bool `json:"view_only"` 23 | } 24 | 25 | func (client *Client) ListDataSources(ctx context.Context) ([]DataSource, error) { 26 | res, close, err := client.Get(ctx, "api/data_sources", nil) 27 | defer close() 28 | 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | dataSources := []DataSource{} 34 | 35 | if err := util.UnmarshalBody(res, &dataSources); err != nil { 36 | return nil, err 37 | } 38 | 39 | return dataSources, nil 40 | } 41 | 42 | func (client *Client) GetDataSource(ctx context.Context, id int) (*DataSource, error) { 43 | res, close, err := client.Get(ctx, fmt.Sprintf("api/data_sources/%d", id), nil) 44 | defer close() 45 | 46 | if err != nil { 47 | return nil, err 48 | } 49 | 50 | dataSource := &DataSource{} 51 | 52 | if err := util.UnmarshalBody(res, &dataSource); err != nil { 53 | return nil, err 54 | } 55 | 56 | return dataSource, nil 57 | } 58 | 59 | type CreateDataSourceInput struct { 60 | Name string `json:"name"` 61 | Options map[string]any `json:"options"` 62 | Type string `json:"type"` 63 | } 64 | 65 | func (client *Client) CreateDataSource(ctx context.Context, input *CreateDataSourceInput) (*DataSource, error) { 66 | res, close, err := client.Post(ctx, "api/data_sources", input) 67 | defer close() 68 | 69 | if err != nil { 70 | return nil, err 71 | } 72 | 73 | dataSource := &DataSource{} 74 | 75 | if err := util.UnmarshalBody(res, &dataSource); err != nil { 76 | return nil, err 77 | } 78 | 79 | return dataSource, nil 80 | } 81 | 82 | type UpdateDataSourceInput struct { 83 | Name string `json:"name"` 84 | Options map[string]any `json:"options"` 85 | Type string `json:"type"` 86 | } 87 | 88 | func (client *Client) UpdateDataSource(ctx context.Context, id int, input *UpdateDataSourceInput) (*DataSource, error) { 89 | res, close, err := client.Post(ctx, fmt.Sprintf("api/data_sources/%d", id), input) 90 | defer close() 91 | 92 | if err != nil { 93 | return nil, err 94 | } 95 | 96 | dataSource := &DataSource{} 97 | 98 | if err := util.UnmarshalBody(res, &dataSource); err != nil { 99 | return nil, err 100 | } 101 | 102 | return dataSource, nil 103 | } 104 | 105 | func (client *Client) DeleteDataSource(ctx context.Context, id int) error { 106 | _, close, err := client.Delete(ctx, fmt.Sprintf("api/data_sources/%d", id)) 107 | defer close() 108 | 109 | if err != nil { 110 | return err 111 | } 112 | 113 | return nil 114 | } 115 | 116 | type PauseDataSourceInput struct { 117 | Reason string `json:"reason,omitempty"` 118 | } 119 | 120 | func (client *Client) PauseDataSource(ctx context.Context, id int, input *PauseDataSourceInput) (*DataSource, error) { 121 | res, close, err := client.Post(ctx, fmt.Sprintf("api/data_sources/%d/pause", id), input) 122 | defer close() 123 | 124 | if err != nil { 125 | return nil, err 126 | } 127 | 128 | dataSource := &DataSource{} 129 | 130 | if err := util.UnmarshalBody(res, &dataSource); err != nil { 131 | return nil, err 132 | } 133 | 134 | return dataSource, nil 135 | } 136 | 137 | func (client *Client) ResumeDataSource(ctx context.Context, id int) (*DataSource, error) { 138 | res, close, err := client.Delete(ctx, fmt.Sprintf("api/data_sources/%d/pause", id)) 139 | defer close() 140 | 141 | if err != nil { 142 | return nil, err 143 | } 144 | 145 | dataSource := &DataSource{} 146 | 147 | if err := util.UnmarshalBody(res, &dataSource); err != nil { 148 | return nil, err 149 | } 150 | 151 | return dataSource, nil 152 | } 153 | 154 | type TestDataSourceOutput struct { 155 | Message string `json:"message"` 156 | Ok bool `json:"ok"` 157 | } 158 | 159 | func (client *Client) TestDataSource(ctx context.Context, id int) (*TestDataSourceOutput, error) { 160 | res, close, err := client.Post(ctx, fmt.Sprintf("api/data_sources/%d/test", id), nil) 161 | defer close() 162 | 163 | if err != nil { 164 | return nil, err 165 | } 166 | 167 | output := &TestDataSourceOutput{} 168 | 169 | if err := util.UnmarshalBody(res, &output); err != nil { 170 | return nil, err 171 | } 172 | 173 | return output, nil 174 | } 175 | 176 | type DataSourceType struct { 177 | ConfigurationSchema DataSourceTypeConfigurationSchema `json:"configuration_schema"` 178 | Name string `json:"name"` 179 | Type string `json:"type"` 180 | } 181 | 182 | type DataSourceTypeConfigurationSchema struct { 183 | ExtraOptions []string `json:"extra_options"` 184 | Order []string `json:"order"` 185 | Properties map[string]DataSourceTypeConfigurationSchemaProperty `json:"properties"` 186 | Required []string `json:"required"` 187 | Secret []string `json:"secret"` 188 | Type string `json:"type"` 189 | } 190 | 191 | type DataSourceTypeConfigurationSchemaProperty struct { 192 | Default any `json:"default"` 193 | Title string `json:"title"` 194 | Type string `json:"type"` 195 | } 196 | 197 | func (client *Client) GetDataSourceTypes(ctx context.Context) ([]DataSourceType, error) { 198 | res, close, err := client.Get(ctx, "api/data_sources/types", nil) 199 | defer close() 200 | 201 | if err != nil { 202 | return nil, err 203 | } 204 | 205 | types := []DataSourceType{} 206 | 207 | if err := util.UnmarshalBody(res, &types); err != nil { 208 | return nil, err 209 | } 210 | 211 | return types, nil 212 | } 213 | 214 | type DataSourceSchemaOutput struct { 215 | Schema []DataSourceSchema `json:"schema"` 216 | } 217 | 218 | type DataSourceSchema struct { 219 | Name string `json:"name"` 220 | Columns []DataSourceSchemaColumn `json:"columns"` 221 | } 222 | 223 | type DataSourceSchemaColumn struct { 224 | Name string `json:"name"` 225 | Type string `json:"type"` 226 | } 227 | 228 | func (client *Client) GetDataSourceSchema(ctx context.Context, id int) (*DataSourceSchemaOutput, error) { 229 | res, close, err := client.Get(ctx, fmt.Sprintf("api/data_sources/%d/schema", id), nil) 230 | defer close() 231 | 232 | if err != nil { 233 | return nil, err 234 | } 235 | 236 | output := &DataSourceSchemaOutput{} 237 | 238 | if err := util.UnmarshalBody(res, &output); err != nil { 239 | return nil, err 240 | } 241 | 242 | return output, nil 243 | } 244 | -------------------------------------------------------------------------------- /data_source_without_ctx.go: -------------------------------------------------------------------------------- 1 | // Code generated from data_source.go using tools/withoutctx.go; DO NOT EDIT. 2 | 3 | package redash 4 | 5 | import "context" 6 | 7 | func (client *ClientWithoutContext) ListDataSources() ([]DataSource, error) { 8 | return client.withCtx.ListDataSources(context.Background()) 9 | } 10 | 11 | func (client *ClientWithoutContext) GetDataSource(id int) (*DataSource, error) { 12 | return client.withCtx.GetDataSource(context.Background(), id) 13 | } 14 | 15 | func (client *ClientWithoutContext) CreateDataSource(input *CreateDataSourceInput) (*DataSource, error) { 16 | return client.withCtx.CreateDataSource(context.Background(), input) 17 | } 18 | 19 | func (client *ClientWithoutContext) UpdateDataSource(id int, input *UpdateDataSourceInput) (*DataSource, error) { 20 | return client.withCtx.UpdateDataSource(context.Background(), id, input) 21 | } 22 | 23 | func (client *ClientWithoutContext) DeleteDataSource(id int) error { 24 | return client.withCtx.DeleteDataSource(context.Background(), id) 25 | } 26 | 27 | func (client *ClientWithoutContext) PauseDataSource(id int, input *PauseDataSourceInput) (*DataSource, error) { 28 | return client.withCtx.PauseDataSource(context.Background(), id, input) 29 | } 30 | 31 | func (client *ClientWithoutContext) ResumeDataSource(id int) (*DataSource, error) { 32 | return client.withCtx.ResumeDataSource(context.Background(), id) 33 | } 34 | 35 | func (client *ClientWithoutContext) TestDataSource(id int) (*TestDataSourceOutput, error) { 36 | return client.withCtx.TestDataSource(context.Background(), id) 37 | } 38 | 39 | func (client *ClientWithoutContext) GetDataSourceTypes() ([]DataSourceType, error) { 40 | return client.withCtx.GetDataSourceTypes(context.Background()) 41 | } 42 | 43 | func (client *ClientWithoutContext) GetDataSourceSchema(id int) (*DataSourceSchemaOutput, error) { 44 | return client.withCtx.GetDataSourceSchema(context.Background(), id) 45 | } 46 | -------------------------------------------------------------------------------- /destinations.go: -------------------------------------------------------------------------------- 1 | //go:generate go run tools/withoutctx.go 2 | package redash 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | 8 | "github.com/winebarrel/redash-go/v2/internal/util" 9 | ) 10 | 11 | type Destination struct { 12 | Icon string `json:"icon"` 13 | ID int `json:"id"` 14 | Name string `json:"name"` 15 | Options map[string]any `json:"options"` 16 | Type string `json:"type"` 17 | } 18 | 19 | func (client *Client) ListDestinations(ctx context.Context) ([]Destination, error) { 20 | res, close, err := client.Get(ctx, "api/destinations", nil) 21 | defer close() 22 | 23 | if err != nil { 24 | return nil, err 25 | } 26 | 27 | destinations := []Destination{} 28 | 29 | if err := util.UnmarshalBody(res, &destinations); err != nil { 30 | return nil, err 31 | } 32 | 33 | return destinations, nil 34 | } 35 | 36 | func (client *Client) GetDestination(ctx context.Context, id int) (*Destination, error) { 37 | res, close, err := client.Get(ctx, fmt.Sprintf("api/destinations/%d", id), nil) 38 | defer close() 39 | 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | dest := &Destination{} 45 | 46 | if err := util.UnmarshalBody(res, &dest); err != nil { 47 | return nil, err 48 | } 49 | 50 | return dest, nil 51 | } 52 | 53 | type CreateDestinationInput struct { 54 | Name string `json:"name"` 55 | Options map[string]any `json:"options"` 56 | Type string `json:"type"` 57 | } 58 | 59 | func (client *Client) CreateDestination(ctx context.Context, input *CreateDestinationInput) (*Destination, error) { 60 | res, close, err := client.Post(ctx, "api/destinations", input) 61 | defer close() 62 | 63 | if err != nil { 64 | return nil, err 65 | } 66 | 67 | dest := &Destination{} 68 | 69 | if err := util.UnmarshalBody(res, &dest); err != nil { 70 | return nil, err 71 | } 72 | 73 | return dest, nil 74 | } 75 | 76 | func (client *Client) DeleteDestination(ctx context.Context, id int) error { 77 | _, close, err := client.Delete(ctx, fmt.Sprintf("api/destinations/%d", id)) 78 | defer close() 79 | 80 | if err != nil { 81 | return err 82 | } 83 | 84 | return nil 85 | } 86 | 87 | type DestinationType struct { 88 | ConfigurationSchema DestinationTypeConfigurationSchema `json:"configuration_schema"` 89 | Icon string `json:"icon"` 90 | Name string `json:"name"` 91 | Type string `json:"type"` 92 | } 93 | 94 | type DestinationTypeConfigurationSchema struct { 95 | ExtraOptions []string `json:"extra_options"` 96 | Properties map[string]DestinationTypeConfigurationSchemaProperty `json:"properties"` 97 | Required []string `json:"required"` 98 | Secret any `json:"secret"` 99 | Type string `json:"type"` 100 | } 101 | 102 | type DestinationTypeConfigurationSchemaProperty struct { 103 | Default any `json:"default"` 104 | Title string `json:"title"` 105 | Type string `json:"type"` 106 | } 107 | 108 | func (client *Client) GetDestinationTypes(ctx context.Context) ([]DestinationType, error) { 109 | res, close, err := client.Get(ctx, "api/destinations/types", nil) 110 | defer close() 111 | 112 | if err != nil { 113 | return nil, err 114 | } 115 | 116 | types := []DestinationType{} 117 | 118 | if err := util.UnmarshalBody(res, &types); err != nil { 119 | return nil, err 120 | } 121 | 122 | return types, nil 123 | } 124 | -------------------------------------------------------------------------------- /destinations_test.go: -------------------------------------------------------------------------------- 1 | package redash_test 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net/http" 7 | "testing" 8 | 9 | "github.com/jarcoal/httpmock" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | "github.com/winebarrel/redash-go/v2" 13 | ) 14 | 15 | func Test_ListDestinations_OK(t *testing.T) { 16 | assert := assert.New(t) 17 | httpmock.Activate() 18 | defer httpmock.DeactivateAndReset() 19 | 20 | httpmock.RegisterResponder(http.MethodGet, "https://redash.example.com/api/destinations", func(req *http.Request) (*http.Response, error) { 21 | assert.Equal( 22 | http.Header( 23 | http.Header{ 24 | "Authorization": []string{"Key " + testRedashAPIKey}, 25 | "Content-Type": []string{"application/json"}, 26 | "User-Agent": []string{"redash-go"}, 27 | }, 28 | ), 29 | req.Header, 30 | ) 31 | return httpmock.NewStringResponse(http.StatusOK, ` 32 | [ 33 | { 34 | "icon": "fa-envelope", 35 | "id": 1, 36 | "name": "alert@example.com", 37 | "type": "email" 38 | } 39 | ] 40 | `), nil 41 | }) 42 | 43 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 44 | res, err := client.ListDestinations(context.Background()) 45 | assert.NoError(err) 46 | assert.Equal([]redash.Destination{ 47 | { 48 | Icon: "fa-envelope", 49 | ID: 1, 50 | Name: "alert@example.com", 51 | Options: nil, 52 | Type: "email", 53 | }, 54 | }, res) 55 | } 56 | 57 | func Test_ListDestinations_Err_5xx(t *testing.T) { 58 | assert := assert.New(t) 59 | httpmock.Activate() 60 | defer httpmock.DeactivateAndReset() 61 | 62 | httpmock.RegisterResponder(http.MethodGet, "https://redash.example.com/api/destinations", func(req *http.Request) (*http.Response, error) { 63 | return httpmock.NewStringResponse(http.StatusServiceUnavailable, "error"), nil 64 | }) 65 | 66 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 67 | _, err := client.ListDestinations(context.Background()) 68 | assert.ErrorContains(err, "GET api/destinations failed: HTTP status code not OK: 503 Service Unavailable\nerror") 69 | } 70 | 71 | func Test_ListDestinations_IOErr(t *testing.T) { 72 | assert := assert.New(t) 73 | httpmock.Activate() 74 | defer httpmock.DeactivateAndReset() 75 | 76 | httpmock.RegisterResponder(http.MethodGet, "https://redash.example.com/api/destinations", func(req *http.Request) (*http.Response, error) { 77 | return testIOErrResp, nil 78 | }) 79 | 80 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 81 | _, err := client.ListDestinations(context.Background()) 82 | assert.ErrorContains(err, "read response body failed: IO error") 83 | } 84 | 85 | func Test_GetDestination_OK(t *testing.T) { 86 | assert := assert.New(t) 87 | httpmock.Activate() 88 | defer httpmock.DeactivateAndReset() 89 | 90 | httpmock.RegisterResponder(http.MethodGet, "https://redash.example.com/api/destinations/1", func(req *http.Request) (*http.Response, error) { 91 | assert.Equal( 92 | http.Header( 93 | http.Header{ 94 | "Authorization": []string{"Key " + testRedashAPIKey}, 95 | "Content-Type": []string{"application/json"}, 96 | "User-Agent": []string{"redash-go"}, 97 | }, 98 | ), 99 | req.Header, 100 | ) 101 | return httpmock.NewStringResponse(http.StatusOK, ` 102 | { 103 | "icon": "fa-envelope", 104 | "id": 1, 105 | "name": "alert@example.com", 106 | "options": { 107 | "addresses": "alert@example.com" 108 | }, 109 | "type": "email" 110 | } 111 | `), nil 112 | }) 113 | 114 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 115 | res, err := client.GetDestination(context.Background(), 1) 116 | assert.NoError(err) 117 | assert.Equal(&redash.Destination{ 118 | Icon: "fa-envelope", 119 | ID: 1, 120 | Name: "alert@example.com", 121 | Options: map[string]any{ 122 | "addresses": "alert@example.com", 123 | }, 124 | Type: "email", 125 | }, res) 126 | } 127 | 128 | func Test_GetDestination_Err_5xx(t *testing.T) { 129 | assert := assert.New(t) 130 | httpmock.Activate() 131 | defer httpmock.DeactivateAndReset() 132 | 133 | httpmock.RegisterResponder(http.MethodGet, "https://redash.example.com/api/destinations/1", func(req *http.Request) (*http.Response, error) { 134 | return httpmock.NewStringResponse(http.StatusServiceUnavailable, "error"), nil 135 | }) 136 | 137 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 138 | _, err := client.GetDestination(context.Background(), 1) 139 | assert.ErrorContains(err, "GET api/destinations/1 failed: HTTP status code not OK: 503 Service Unavailable\nerror") 140 | } 141 | 142 | func Test_GetDestination_IOErr(t *testing.T) { 143 | assert := assert.New(t) 144 | httpmock.Activate() 145 | defer httpmock.DeactivateAndReset() 146 | 147 | httpmock.RegisterResponder(http.MethodGet, "https://redash.example.com/api/destinations/1", func(req *http.Request) (*http.Response, error) { 148 | return testIOErrResp, nil 149 | }) 150 | 151 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 152 | _, err := client.GetDestination(context.Background(), 1) 153 | assert.ErrorContains(err, "read response body failed: IO error") 154 | } 155 | 156 | func Test_CreateDestination_OK(t *testing.T) { 157 | assert := assert.New(t) 158 | httpmock.Activate() 159 | defer httpmock.DeactivateAndReset() 160 | 161 | httpmock.RegisterResponder(http.MethodPost, "https://redash.example.com/api/destinations", func(req *http.Request) (*http.Response, error) { 162 | assert.Equal( 163 | http.Header( 164 | http.Header{ 165 | "Authorization": []string{"Key " + testRedashAPIKey}, 166 | "Content-Type": []string{"application/json"}, 167 | "User-Agent": []string{"redash-go"}, 168 | }, 169 | ), 170 | req.Header, 171 | ) 172 | if req.Body == nil { 173 | assert.FailNow("req.Body is nil") 174 | } 175 | body, _ := io.ReadAll(req.Body) 176 | assert.Equal(`{"name":"alert@example.com","options":{"addresses":"alert@example.com"},"type":"email"}`, string(body)) 177 | return httpmock.NewStringResponse(http.StatusOK, ` 178 | { 179 | "icon": "fa-envelope", 180 | "id": 1, 181 | "name": "alert@example.com", 182 | "options": { 183 | "addresses": "alert@example.com" 184 | }, 185 | "type": "email" 186 | } 187 | `), nil 188 | }) 189 | 190 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 191 | res, err := client.CreateDestination(context.Background(), &redash.CreateDestinationInput{ 192 | Name: "alert@example.com", 193 | Options: map[string]any{ 194 | "addresses": "alert@example.com", 195 | }, 196 | Type: "email", 197 | }) 198 | assert.NoError(err) 199 | assert.Equal(&redash.Destination{ 200 | Icon: "fa-envelope", 201 | ID: 1, 202 | Name: "alert@example.com", 203 | Options: map[string]any{ 204 | "addresses": "alert@example.com", 205 | }, 206 | Type: "email", 207 | }, res) 208 | } 209 | 210 | func Test_CreateDestination_Err_5xx(t *testing.T) { 211 | assert := assert.New(t) 212 | httpmock.Activate() 213 | defer httpmock.DeactivateAndReset() 214 | 215 | httpmock.RegisterResponder(http.MethodPost, "https://redash.example.com/api/destinations", func(req *http.Request) (*http.Response, error) { 216 | return httpmock.NewStringResponse(http.StatusServiceUnavailable, "error"), nil 217 | }) 218 | 219 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 220 | _, err := client.CreateDestination(context.Background(), &redash.CreateDestinationInput{ 221 | Name: "alert@example.com", 222 | Options: map[string]any{ 223 | "addresses": "alert@example.com", 224 | }, 225 | Type: "email", 226 | }) 227 | assert.ErrorContains(err, "POST api/destinations failed: HTTP status code not OK: 503 Service Unavailable\nerror") 228 | } 229 | 230 | func Test_CreateDestination_IOErr(t *testing.T) { 231 | assert := assert.New(t) 232 | httpmock.Activate() 233 | defer httpmock.DeactivateAndReset() 234 | 235 | httpmock.RegisterResponder(http.MethodPost, "https://redash.example.com/api/destinations", func(req *http.Request) (*http.Response, error) { 236 | return testIOErrResp, nil 237 | }) 238 | 239 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 240 | _, err := client.CreateDestination(context.Background(), &redash.CreateDestinationInput{ 241 | Name: "alert@example.com", 242 | Options: map[string]any{ 243 | "addresses": "alert@example.com", 244 | }, 245 | Type: "email", 246 | }) 247 | assert.ErrorContains(err, "read response body failed: IO error") 248 | } 249 | 250 | func Test_DeleteDestination_OK(t *testing.T) { 251 | assert := assert.New(t) 252 | httpmock.Activate() 253 | defer httpmock.DeactivateAndReset() 254 | 255 | httpmock.RegisterResponder(http.MethodDelete, "https://redash.example.com/api/destinations/1", func(req *http.Request) (*http.Response, error) { 256 | assert.Equal( 257 | http.Header( 258 | http.Header{ 259 | "Authorization": []string{"Key " + testRedashAPIKey}, 260 | "Content-Type": []string{"application/json"}, 261 | "User-Agent": []string{"redash-go"}, 262 | }, 263 | ), 264 | req.Header, 265 | ) 266 | return httpmock.NewStringResponse(http.StatusOK, ``), nil 267 | }) 268 | 269 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 270 | err := client.DeleteDestination(context.Background(), 1) 271 | assert.NoError(err) 272 | } 273 | 274 | func Test_DeleteDestination_Err_5xx(t *testing.T) { 275 | assert := assert.New(t) 276 | httpmock.Activate() 277 | defer httpmock.DeactivateAndReset() 278 | 279 | httpmock.RegisterResponder(http.MethodDelete, "https://redash.example.com/api/destinations/1", func(req *http.Request) (*http.Response, error) { 280 | return httpmock.NewStringResponse(http.StatusServiceUnavailable, "error"), nil 281 | }) 282 | 283 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 284 | err := client.DeleteDestination(context.Background(), 1) 285 | assert.ErrorContains(err, "DELETE api/destinations/1 failed: HTTP status code not OK: 503 Service Unavailable\nerror") 286 | } 287 | 288 | func Test_GetDestinationTypes_OK(t *testing.T) { 289 | assert := assert.New(t) 290 | httpmock.Activate() 291 | defer httpmock.DeactivateAndReset() 292 | 293 | httpmock.RegisterResponder(http.MethodGet, "https://redash.example.com/api/destinations/types", func(req *http.Request) (*http.Response, error) { 294 | assert.Equal( 295 | http.Header( 296 | http.Header{ 297 | "Authorization": []string{"Key " + testRedashAPIKey}, 298 | "Content-Type": []string{"application/json"}, 299 | "User-Agent": []string{"redash-go"}, 300 | }, 301 | ), 302 | req.Header, 303 | ) 304 | return httpmock.NewStringResponse(http.StatusOK, ` 305 | [ 306 | { 307 | "configuration_schema": { 308 | "extra_options": [ 309 | "subject_template" 310 | ], 311 | "properties": { 312 | "icon_url": { 313 | "title": "Icon URL (32x32 or multiple, png format)", 314 | "type": "string" 315 | }, 316 | "url": { 317 | "title": "Webhook URL (get it from the room settings)", 318 | "type": "string" 319 | }, 320 | "subject_template": { 321 | "default": "({state}) {alert_name}", 322 | "title": "Subject Template", 323 | "type": "string" 324 | } 325 | }, 326 | "required": [ 327 | "url" 328 | ], 329 | "secret": [ 330 | "url" 331 | ], 332 | "type": "object" 333 | }, 334 | "icon": "fa-bolt", 335 | "name": "Google Hangouts Chat", 336 | "type": "hangouts_chat" 337 | } 338 | ] 339 | `), nil 340 | }) 341 | 342 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 343 | res, err := client.GetDestinationTypes(context.Background()) 344 | assert.NoError(err) 345 | assert.Equal([]redash.DestinationType{ 346 | { 347 | ConfigurationSchema: redash.DestinationTypeConfigurationSchema{ 348 | ExtraOptions: []string{ 349 | "subject_template", 350 | }, 351 | Properties: map[string]redash.DestinationTypeConfigurationSchemaProperty{ 352 | "icon_url": { 353 | Title: "Icon URL (32x32 or multiple, png format)", 354 | Type: "string", 355 | }, 356 | "url": { 357 | Title: "Webhook URL (get it from the room settings)", 358 | Type: "string", 359 | }, 360 | "subject_template": { 361 | Default: "({state}) {alert_name}", 362 | Title: "Subject Template", 363 | Type: "string", 364 | }, 365 | }, 366 | Required: []string{ 367 | "url", 368 | }, 369 | Secret: []any{ 370 | "url", 371 | }, 372 | Type: "object", 373 | }, 374 | Icon: "fa-bolt", 375 | Name: "Google Hangouts Chat", 376 | Type: "hangouts_chat", 377 | }, 378 | }, res) 379 | } 380 | 381 | func Test_GetDestinationTypes_Err_5xx(t *testing.T) { 382 | assert := assert.New(t) 383 | httpmock.Activate() 384 | defer httpmock.DeactivateAndReset() 385 | 386 | httpmock.RegisterResponder(http.MethodGet, "https://redash.example.com/api/destinations/types", func(req *http.Request) (*http.Response, error) { 387 | return httpmock.NewStringResponse(http.StatusServiceUnavailable, "error"), nil 388 | }) 389 | 390 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 391 | _, err := client.GetDestinationTypes(context.Background()) 392 | assert.ErrorContains(err, "GET api/destinations/types failed: HTTP status code not OK: 503 Service Unavailable\nerror") 393 | } 394 | 395 | func Test_GetDestinationTypes_IOErr(t *testing.T) { 396 | assert := assert.New(t) 397 | httpmock.Activate() 398 | defer httpmock.DeactivateAndReset() 399 | 400 | httpmock.RegisterResponder(http.MethodGet, "https://redash.example.com/api/destinations/types", func(req *http.Request) (*http.Response, error) { 401 | return testIOErrResp, nil 402 | }) 403 | 404 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 405 | _, err := client.GetDestinationTypes(context.Background()) 406 | assert.ErrorContains(err, "read response body failed: IO error") 407 | } 408 | 409 | func Test_Destination_Acc(t *testing.T) { 410 | if !testAcc { 411 | t.Skip() 412 | } 413 | 414 | assert := assert.New(t) 415 | require := require.New(t) 416 | client, _ := redash.NewClient(testRedashEndpoint, testRedashAPIKey) 417 | 418 | _, err := client.ListDestinations(context.Background()) 419 | assert.NoError(err) 420 | 421 | dest, err := client.CreateDestination(context.Background(), &redash.CreateDestinationInput{ 422 | Name: "test-dest-1", 423 | Options: map[string]any{ 424 | "addresses": "alert@example.com", 425 | }, 426 | Type: "email", 427 | }) 428 | require.NoError(err) 429 | assert.Equal("test-dest-1", dest.Name) 430 | 431 | dest, err = client.GetDestination(context.Background(), dest.ID) 432 | require.NoError(err) 433 | assert.Equal("test-dest-1", dest.Name) 434 | 435 | err = client.DeleteDestination(context.Background(), dest.ID) 436 | require.NoError(err) 437 | 438 | _, err = client.GetDestination(context.Background(), dest.ID) 439 | assert.Error(err) 440 | 441 | types, err := client.GetDestinationTypes(context.Background()) 442 | require.NoError(err) 443 | assert.GreaterOrEqual(len(types), 1) 444 | } 445 | -------------------------------------------------------------------------------- /destinations_without_ctx.go: -------------------------------------------------------------------------------- 1 | // Code generated from destinations.go using tools/withoutctx.go; DO NOT EDIT. 2 | 3 | package redash 4 | 5 | import "context" 6 | 7 | func (client *ClientWithoutContext) ListDestinations() ([]Destination, error) { 8 | return client.withCtx.ListDestinations(context.Background()) 9 | } 10 | 11 | func (client *ClientWithoutContext) GetDestination(id int) (*Destination, error) { 12 | return client.withCtx.GetDestination(context.Background(), id) 13 | } 14 | 15 | func (client *ClientWithoutContext) CreateDestination(input *CreateDestinationInput) (*Destination, error) { 16 | return client.withCtx.CreateDestination(context.Background(), input) 17 | } 18 | 19 | func (client *ClientWithoutContext) DeleteDestination(id int) error { 20 | return client.withCtx.DeleteDestination(context.Background(), id) 21 | } 22 | 23 | func (client *ClientWithoutContext) GetDestinationTypes() ([]DestinationType, error) { 24 | return client.withCtx.GetDestinationTypes(context.Background()) 25 | } 26 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Redash API client for Go that supports almost all APIs. 3 | */ 4 | package redash 5 | -------------------------------------------------------------------------------- /event.go: -------------------------------------------------------------------------------- 1 | //go:generate go run tools/withoutctx.go 2 | package redash 3 | 4 | import ( 5 | "context" 6 | "time" 7 | 8 | "github.com/winebarrel/redash-go/v2/internal/util" 9 | ) 10 | 11 | type EventPage struct { 12 | Count int `json:"count"` 13 | Page int `json:"page"` 14 | PageSize int `json:"page_size"` 15 | Results []Event `json:"results"` 16 | } 17 | 18 | type Event struct { 19 | Action string `json:"action"` 20 | Browser string `json:"browser"` 21 | CreatedAt time.Time `json:"created_at"` 22 | Details map[string]string `json:"details"` 23 | Location string `json:"location"` 24 | ObjectID string `json:"object_id"` 25 | ObjectType string `json:"object_type"` 26 | OrgID int `json:"org_id"` 27 | UserID int `json:"user_id"` 28 | UserName string `json:"user_name"` 29 | } 30 | 31 | type ListEventsInput struct { 32 | Page int `url:"page,omitempty"` 33 | PageSize int `url:"page_size,omitempty"` 34 | } 35 | 36 | func (client *Client) ListEvents(ctx context.Context, input *ListEventsInput) (*EventPage, error) { 37 | res, close, err := client.Get(ctx, "api/events", input) 38 | defer close() 39 | 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | page := &EventPage{} 45 | 46 | if err := util.UnmarshalBody(res, &page); err != nil { 47 | return nil, err 48 | } 49 | 50 | return page, nil 51 | } 52 | -------------------------------------------------------------------------------- /event_test.go: -------------------------------------------------------------------------------- 1 | package redash_test 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/araddon/dateparse" 9 | "github.com/jarcoal/httpmock" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | "github.com/winebarrel/redash-go/v2" 13 | ) 14 | 15 | func Test_ListEvents_OK(t *testing.T) { 16 | assert := assert.New(t) 17 | httpmock.Activate() 18 | defer httpmock.DeactivateAndReset() 19 | 20 | httpmock.RegisterResponder(http.MethodGet, "https://redash.example.com/api/events", func(req *http.Request) (*http.Response, error) { 21 | assert.Equal( 22 | http.Header( 23 | http.Header{ 24 | "Authorization": []string{"Key " + testRedashAPIKey}, 25 | "Content-Type": []string{"application/json"}, 26 | "User-Agent": []string{"redash-go"}, 27 | }, 28 | ), 29 | req.Header, 30 | ) 31 | assert.Equal("page=1&page_size=25", req.URL.Query().Encode()) 32 | return httpmock.NewStringResponse(http.StatusOK, ` 33 | { 34 | "count": 1, 35 | "page": 1, 36 | "page_size": 25, 37 | "results": [ 38 | { 39 | "action": "create", 40 | "browser": "Other / Other / Other", 41 | "created_at": "2023-02-10T01:23:45.000Z", 42 | "details": { 43 | "object_id": "44", 44 | "object_type": "datasource" 45 | }, 46 | "location": "Unknown", 47 | "object_id": "44", 48 | "object_type": "datasource", 49 | "org_id": 1, 50 | "user_id": 1, 51 | "user_name": "admin" 52 | } 53 | ] 54 | } 55 | `), nil 56 | }) 57 | 58 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 59 | res, err := client.ListEvents(context.Background(), &redash.ListEventsInput{ 60 | Page: 1, 61 | PageSize: 25, 62 | }) 63 | assert.NoError(err) 64 | assert.Equal(&redash.EventPage{ 65 | Count: 1, 66 | Page: 1, 67 | PageSize: 25, 68 | Results: []redash.Event{ 69 | { 70 | Action: "create", 71 | Browser: "Other / Other / Other", 72 | CreatedAt: dateparse.MustParse("2023-02-10T01:23:45.000Z"), 73 | Details: map[string]string{ 74 | "object_id": "44", 75 | "object_type": "datasource", 76 | }, 77 | Location: "Unknown", 78 | ObjectID: "44", 79 | ObjectType: "datasource", 80 | OrgID: 1, 81 | UserID: 1, 82 | UserName: "admin", 83 | }, 84 | }, 85 | }, res) 86 | } 87 | 88 | func Test_ListEvents_Err_5xx(t *testing.T) { 89 | assert := assert.New(t) 90 | httpmock.Activate() 91 | defer httpmock.DeactivateAndReset() 92 | 93 | httpmock.RegisterResponder(http.MethodGet, "https://redash.example.com/api/events", func(req *http.Request) (*http.Response, error) { 94 | return httpmock.NewStringResponse(http.StatusServiceUnavailable, "error"), nil 95 | }) 96 | 97 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 98 | _, err := client.ListEvents(context.Background(), &redash.ListEventsInput{ 99 | Page: 1, 100 | PageSize: 25, 101 | }) 102 | assert.ErrorContains(err, "GET api/events failed: HTTP status code not OK: 503 Service Unavailable\nerror") 103 | } 104 | 105 | func Test_ListEvents_IOErr(t *testing.T) { 106 | assert := assert.New(t) 107 | httpmock.Activate() 108 | defer httpmock.DeactivateAndReset() 109 | 110 | httpmock.RegisterResponder(http.MethodGet, "https://redash.example.com/api/events", func(req *http.Request) (*http.Response, error) { 111 | return testIOErrResp, nil 112 | }) 113 | 114 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 115 | _, err := client.ListEvents(context.Background(), &redash.ListEventsInput{ 116 | Page: 1, 117 | PageSize: 25, 118 | }) 119 | assert.ErrorContains(err, "read response body failed: IO error") 120 | } 121 | 122 | func Test_Event_Acc(t *testing.T) { 123 | if !testAcc { 124 | t.Skip() 125 | } 126 | 127 | assert := assert.New(t) 128 | require := require.New(t) 129 | client, _ := redash.NewClient(testRedashEndpoint, testRedashAPIKey) 130 | 131 | ds, err := client.CreateDataSource(context.Background(), &redash.CreateDataSourceInput{ 132 | Name: "test-postgres-1", 133 | Type: "pg", 134 | Options: map[string]any{ 135 | "dbname": "postgres", 136 | "host": "postgres", 137 | "port": 5432, 138 | "user": "postgres", 139 | }, 140 | }) 141 | require.NoError(err) 142 | 143 | defer func() { 144 | client.DeleteDataSource(context.Background(), ds.ID) //nolint:errcheck 145 | }() 146 | 147 | page, err := client.ListEvents(context.Background(), &redash.ListEventsInput{ 148 | Page: 1, 149 | PageSize: 25, 150 | }) 151 | 152 | require.NoError(err) 153 | assert.Equal(1, page.Page) 154 | assert.Equal(25, page.PageSize) 155 | assert.NotEqual(0, page.Count) 156 | 157 | if len(page.Results) == 0 { 158 | assert.FailNow("len(page.Results) == 0") 159 | } 160 | 161 | assert.NotEqual(0, len(page.Results)) 162 | assert.Equal("admin", page.Results[0].UserName) 163 | } 164 | -------------------------------------------------------------------------------- /event_without_ctx.go: -------------------------------------------------------------------------------- 1 | // Code generated from event.go using tools/withoutctx.go; DO NOT EDIT. 2 | 3 | package redash 4 | 5 | import "context" 6 | 7 | func (client *ClientWithoutContext) ListEvents(input *ListEventsInput) (*EventPage, error) { 8 | return client.withCtx.ListEvents(context.Background(), input) 9 | } 10 | -------------------------------------------------------------------------------- /examples/go.mod: -------------------------------------------------------------------------------- 1 | module example 2 | 3 | go 1.23.4 4 | 5 | replace github.com/winebarrel/redash-go/v2 => ../ 6 | 7 | require github.com/winebarrel/redash-go/v2 v2.0.0-00010101000000-000000000000 8 | 9 | require github.com/google/go-querystring v1.1.0 // indirect 10 | -------------------------------------------------------------------------------- /examples/go.sum: -------------------------------------------------------------------------------- 1 | github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA= 2 | github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw= 3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM= 6 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 7 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 8 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 9 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 10 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 11 | github.com/jarcoal/httpmock v1.3.1 h1:iUx3whfZWVf3jT01hQTO/Eo5sAYtB2/rqaUuOtpInww= 12 | github.com/jarcoal/httpmock v1.3.1/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg= 13 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 14 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 15 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 16 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 17 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 18 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 19 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 20 | -------------------------------------------------------------------------------- /examples/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | 8 | "github.com/winebarrel/redash-go/v2" 9 | ) 10 | 11 | const ( 12 | testRedashEndpoint = "http://localhost:5001" 13 | testRedashAPIKey = "6nh64ZsT66WeVJvNZ6WB5D2JKZULeC2VBdSD68wt" 14 | ) 15 | 16 | func main() { 17 | client := redash.MustNewClient(testRedashEndpoint, testRedashAPIKey) 18 | // client.SetDebug(true) 19 | ctx := context.Background() 20 | 21 | ds, err := client.CreateDataSource(ctx, &redash.CreateDataSourceInput{ 22 | Name: "postgres", 23 | Type: "pg", 24 | Options: map[string]any{ 25 | "dbname": "postgres", 26 | "host": "postgres", 27 | "port": 5432, 28 | "user": "postgres", 29 | }, 30 | }) 31 | 32 | if err != nil { 33 | panic(err) 34 | } 35 | 36 | query, err := client.CreateQuery(ctx, &redash.CreateQueryInput{ 37 | DataSourceID: ds.ID, 38 | Name: "my-query1", 39 | Query: "select 1", 40 | }) 41 | 42 | if err != nil { 43 | panic(err) 44 | } 45 | 46 | var buf bytes.Buffer 47 | job, err := client.ExecQueryJSON(ctx, query.ID, nil, &buf) 48 | 49 | if err != nil { 50 | panic(err) 51 | } 52 | 53 | err = client.WaitQueryJSON(ctx, query.ID, job, nil, &buf) 54 | 55 | if err != nil { 56 | panic(err) 57 | } 58 | 59 | fmt.Println(buf.String()) 60 | } 61 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/winebarrel/redash-go/v2 2 | 3 | go 1.24.0 4 | 5 | toolchain go1.25.2 6 | 7 | require ( 8 | github.com/jarcoal/httpmock v1.4.1 9 | github.com/stretchr/testify v1.11.1 10 | ) 11 | 12 | require ( 13 | github.com/google/uuid v1.6.0 14 | golang.org/x/tools v0.38.0 15 | ) 16 | 17 | require golang.org/x/sync v0.17.0 // indirect 18 | 19 | require ( 20 | github.com/google/go-querystring v1.1.0 21 | golang.org/x/mod v0.29.0 // indirect 22 | ) 23 | 24 | require ( 25 | github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de 26 | github.com/davecgh/go-spew v1.1.1 // indirect 27 | github.com/pmezard/go-difflib v1.0.0 // indirect 28 | gopkg.in/yaml.v3 v3.0.1 // indirect 29 | ) 30 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA= 2 | github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw= 3 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 5 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 7 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 8 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 9 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 10 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 11 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 12 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 13 | github.com/jarcoal/httpmock v1.4.1 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A= 14 | github.com/jarcoal/httpmock v1.4.1/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0= 15 | github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 16 | github.com/maxatome/go-testdeep v1.14.0 h1:rRlLv1+kI8eOI3OaBXZwb3O7xY3exRzdW5QyX48g9wI= 17 | github.com/maxatome/go-testdeep v1.14.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM= 18 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 19 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 20 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 21 | github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg= 22 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 23 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 24 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 25 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 26 | golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= 27 | golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= 28 | golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= 29 | golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 30 | golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= 31 | golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= 32 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 33 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 34 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 35 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 36 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 37 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 38 | -------------------------------------------------------------------------------- /group.go: -------------------------------------------------------------------------------- 1 | //go:generate go run tools/withoutctx.go 2 | package redash 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | "time" 8 | 9 | "github.com/winebarrel/redash-go/v2/internal/util" 10 | ) 11 | 12 | type Group struct { 13 | CreatedAt time.Time `json:"created_at"` 14 | ID int `json:"id"` 15 | Name string `json:"name"` 16 | Permissions []string `json:"permissions"` 17 | Type string `json:"type"` 18 | } 19 | 20 | func (client *Client) ListGroups(ctx context.Context) ([]Group, error) { 21 | res, close, err := client.Get(ctx, "api/groups", nil) 22 | defer close() 23 | 24 | if err != nil { 25 | return nil, err 26 | } 27 | 28 | groups := []Group{} 29 | 30 | if err := util.UnmarshalBody(res, &groups); err != nil { 31 | return nil, err 32 | } 33 | 34 | return groups, nil 35 | } 36 | 37 | func (client *Client) GetGroup(ctx context.Context, id int) (*Group, error) { 38 | res, close, err := client.Get(ctx, fmt.Sprintf("api/groups/%d", id), nil) 39 | defer close() 40 | 41 | if err != nil { 42 | return nil, err 43 | } 44 | 45 | group := &Group{} 46 | 47 | if err := util.UnmarshalBody(res, &group); err != nil { 48 | return nil, err 49 | } 50 | 51 | return group, nil 52 | } 53 | 54 | type CreateGroupInput struct { 55 | Name string `json:"name"` 56 | } 57 | 58 | func (client *Client) CreateGroup(ctx context.Context, input *CreateGroupInput) (*Group, error) { 59 | res, close, err := client.Post(ctx, "api/groups", input) 60 | defer close() 61 | 62 | if err != nil { 63 | return nil, err 64 | } 65 | 66 | group := &Group{} 67 | 68 | if err := util.UnmarshalBody(res, &group); err != nil { 69 | return nil, err 70 | } 71 | 72 | return group, nil 73 | } 74 | 75 | func (client *Client) DeleteGroup(ctx context.Context, id int) error { 76 | _, close, err := client.Delete(ctx, fmt.Sprintf("api/groups/%d", id)) 77 | defer close() 78 | 79 | if err != nil { 80 | return err 81 | } 82 | 83 | return nil 84 | } 85 | 86 | func (client *Client) ListGroupMembers(ctx context.Context, id int) ([]User, error) { 87 | res, close, err := client.Get(ctx, fmt.Sprintf("api/groups/%d/members", id), nil) 88 | defer close() 89 | 90 | if err != nil { 91 | return nil, err 92 | } 93 | 94 | users := []User{} 95 | 96 | if err := util.UnmarshalBody(res, &users); err != nil { 97 | return nil, err 98 | } 99 | 100 | return users, nil 101 | } 102 | 103 | func (client *Client) AddGroupMember(ctx context.Context, id int, userId int) (*User, error) { 104 | res, close, err := client.Post(ctx, fmt.Sprintf("api/groups/%d/members", id), map[string]int{"user_id": userId}) 105 | defer close() 106 | 107 | if err != nil { 108 | return nil, err 109 | } 110 | 111 | user := &User{} 112 | 113 | if err := util.UnmarshalBody(res, &user); err != nil { 114 | return nil, err 115 | } 116 | 117 | return user, nil 118 | } 119 | 120 | func (client *Client) RemoveGroupMember(ctx context.Context, id int, userId int) error { 121 | _, close, err := client.Delete(ctx, fmt.Sprintf("api/groups/%d/members/%d", id, userId)) 122 | defer close() 123 | 124 | if err != nil { 125 | return err 126 | } 127 | 128 | return nil 129 | } 130 | 131 | func (client *Client) ListGroupDataSources(ctx context.Context, id int) ([]DataSource, error) { 132 | res, close, err := client.Get(ctx, fmt.Sprintf("api/groups/%d/data_sources", id), nil) 133 | defer close() 134 | 135 | if err != nil { 136 | return nil, err 137 | } 138 | 139 | dataSources := []DataSource{} 140 | 141 | if err := util.UnmarshalBody(res, &dataSources); err != nil { 142 | return nil, err 143 | } 144 | 145 | return dataSources, nil 146 | } 147 | 148 | func (client *Client) AddGroupDataSource(ctx context.Context, id int, dsId int) (*DataSource, error) { 149 | res, close, err := client.Post(ctx, fmt.Sprintf("api/groups/%d/data_sources", id), map[string]int{"data_source_id": dsId}) 150 | defer close() 151 | 152 | if err != nil { 153 | return nil, err 154 | } 155 | 156 | dataSource := &DataSource{} 157 | 158 | if err := util.UnmarshalBody(res, &dataSource); err != nil { 159 | return nil, err 160 | } 161 | 162 | return dataSource, nil 163 | } 164 | 165 | func (client *Client) RemoveGroupDataSource(ctx context.Context, id int, dsId int) error { 166 | _, close, err := client.Delete(ctx, fmt.Sprintf("api/groups/%d/data_sources/%d", id, dsId)) 167 | defer close() 168 | 169 | if err != nil { 170 | return err 171 | } 172 | 173 | return nil 174 | } 175 | 176 | type UpdateGroupDataSourceInput struct { 177 | ViewOnly bool `json:"view_only"` 178 | } 179 | 180 | func (client *Client) UpdateGroupDataSource(ctx context.Context, id int, dsId int, input *UpdateGroupDataSourceInput) (*DataSource, error) { 181 | res, close, err := client.Post(ctx, fmt.Sprintf("api/groups/%d/data_sources/%d", id, dsId), input) 182 | defer close() 183 | 184 | if err != nil { 185 | return nil, err 186 | } 187 | 188 | dataSource := &DataSource{} 189 | 190 | if err := util.UnmarshalBody(res, &dataSource); err != nil { 191 | return nil, err 192 | } 193 | 194 | return dataSource, nil 195 | } 196 | -------------------------------------------------------------------------------- /group_without_ctx.go: -------------------------------------------------------------------------------- 1 | // Code generated from group.go using tools/withoutctx.go; DO NOT EDIT. 2 | 3 | package redash 4 | 5 | import "context" 6 | 7 | func (client *ClientWithoutContext) ListGroups() ([]Group, error) { 8 | return client.withCtx.ListGroups(context.Background()) 9 | } 10 | 11 | func (client *ClientWithoutContext) GetGroup(id int) (*Group, error) { 12 | return client.withCtx.GetGroup(context.Background(), id) 13 | } 14 | 15 | func (client *ClientWithoutContext) CreateGroup(input *CreateGroupInput) (*Group, error) { 16 | return client.withCtx.CreateGroup(context.Background(), input) 17 | } 18 | 19 | func (client *ClientWithoutContext) DeleteGroup(id int) error { 20 | return client.withCtx.DeleteGroup(context.Background(), id) 21 | } 22 | 23 | func (client *ClientWithoutContext) ListGroupMembers(id int) ([]User, error) { 24 | return client.withCtx.ListGroupMembers(context.Background(), id) 25 | } 26 | 27 | func (client *ClientWithoutContext) AddGroupMember(id int, userId int) (*User, error) { 28 | return client.withCtx.AddGroupMember(context.Background(), id, userId) 29 | } 30 | 31 | func (client *ClientWithoutContext) RemoveGroupMember(id int, userId int) error { 32 | return client.withCtx.RemoveGroupMember(context.Background(), id, userId) 33 | } 34 | 35 | func (client *ClientWithoutContext) ListGroupDataSources(id int) ([]DataSource, error) { 36 | return client.withCtx.ListGroupDataSources(context.Background(), id) 37 | } 38 | 39 | func (client *ClientWithoutContext) AddGroupDataSource(id int, dsId int) (*DataSource, error) { 40 | return client.withCtx.AddGroupDataSource(context.Background(), id, dsId) 41 | } 42 | 43 | func (client *ClientWithoutContext) RemoveGroupDataSource(id int, dsId int) error { 44 | return client.withCtx.RemoveGroupDataSource(context.Background(), id, dsId) 45 | } 46 | 47 | func (client *ClientWithoutContext) UpdateGroupDataSource(id int, dsId int, input *UpdateGroupDataSourceInput) (*DataSource, error) { 48 | return client.withCtx.UpdateGroupDataSource(context.Background(), id, dsId, input) 49 | } 50 | -------------------------------------------------------------------------------- /internal/util/http.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "net/url" 10 | 11 | "github.com/google/go-querystring/query" 12 | ) 13 | 14 | func UnmarshalBody(res *http.Response, v any) error { 15 | body, err := io.ReadAll(res.Body) 16 | 17 | if err != nil { 18 | return fmt.Errorf("read response body failed: %w", err) 19 | } 20 | 21 | err = json.Unmarshal(body, v) 22 | 23 | if err != nil { 24 | return fmt.Errorf("unmarshal response body failed: %w", err) 25 | } 26 | 27 | return nil 28 | } 29 | 30 | func CheckStatus(res *http.Response) error { 31 | if 200 <= res.StatusCode && res.StatusCode <= 299 { 32 | return nil 33 | } 34 | 35 | msg := fmt.Sprintf("HTTP status code not OK: %s", res.Status) 36 | 37 | if res.Body != nil { 38 | body, err := io.ReadAll(res.Body) 39 | 40 | if err == nil && len(body) > 0 { 41 | msg += "\n" + string(body) 42 | } 43 | } 44 | 45 | return errors.New(msg) 46 | } 47 | 48 | func CloseResponse(res *http.Response) { 49 | if res == nil || res.Body == nil { 50 | return 51 | } 52 | 53 | io.Copy(io.Discard, res.Body) //nolint:errcheck 54 | res.Body.Close() 55 | } 56 | 57 | func URLValuesFrom(params any) (url.Values, error) { 58 | if m, ok := params.(map[string]string); ok { 59 | values := url.Values{} 60 | 61 | for k, v := range m { 62 | values.Add(k, v) 63 | } 64 | 65 | return values, nil 66 | } else { 67 | values, err := query.Values(params) 68 | 69 | if err != nil { 70 | return nil, err 71 | } 72 | 73 | return values, nil 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /internal/util/http_test.go: -------------------------------------------------------------------------------- 1 | package util_test 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "strings" 9 | "testing" 10 | "testing/iotest" 11 | 12 | "github.com/stretchr/testify/assert" 13 | "github.com/winebarrel/redash-go/v2/internal/util" 14 | ) 15 | 16 | func Test_UnmarshalBody_OK(t *testing.T) { 17 | assert := assert.New(t) 18 | 19 | res := &http.Response{ 20 | Body: io.NopCloser(strings.NewReader(`{"foo":"bar"}`)), 21 | } 22 | 23 | var body map[string]string 24 | err := util.UnmarshalBody(res, &body) 25 | assert.NoError(err) 26 | assert.Equal(map[string]string{"foo": "bar"}, body) 27 | } 28 | 29 | func Test_UnmarshalBody_IOErr(t *testing.T) { 30 | assert := assert.New(t) 31 | 32 | res := &http.Response{ 33 | Body: io.NopCloser(iotest.ErrReader(errors.New("IO error"))), 34 | } 35 | 36 | var body map[string]string 37 | err := util.UnmarshalBody(res, &body) 38 | assert.ErrorContains(err, "read response body failed: IO error") 39 | } 40 | 41 | func Test_UnmarshalBody_Err(t *testing.T) { 42 | assert := assert.New(t) 43 | 44 | res := &http.Response{ 45 | Body: io.NopCloser(strings.NewReader(`{"foo":"`)), 46 | } 47 | 48 | var body map[string]string 49 | err := util.UnmarshalBody(res, &body) 50 | assert.Error(err) 51 | } 52 | 53 | func Test_CheckStatus_OK(t *testing.T) { 54 | assert := assert.New(t) 55 | tt := []int{200, 299} 56 | 57 | for _, t := range tt { 58 | res := &http.Response{ 59 | StatusCode: t, 60 | } 61 | 62 | err := util.CheckStatus(res) 63 | assert.NoError(err) 64 | } 65 | } 66 | 67 | func Test_CheckStatus_Err(t *testing.T) { 68 | assert := assert.New(t) 69 | tt := []int{300, 400, 500} 70 | 71 | for _, t := range tt { 72 | res := &http.Response{ 73 | StatusCode: t, 74 | Status: fmt.Sprintf("STATUS CODE %d", t), 75 | } 76 | 77 | err := util.CheckStatus(res) 78 | assert.ErrorContains(err, fmt.Sprintf("HTTP status code not OK: STATUS CODE %d", t)) 79 | } 80 | } 81 | 82 | func Test_CheckStatus_Err_WithBody(t *testing.T) { 83 | assert := assert.New(t) 84 | tt := []int{300, 400, 500} 85 | 86 | for _, t := range tt { 87 | res := &http.Response{ 88 | StatusCode: t, 89 | Status: fmt.Sprintf("STATUS CODE %d", t), 90 | Body: io.NopCloser(strings.NewReader(`body`)), 91 | } 92 | 93 | err := util.CheckStatus(res) 94 | assert.ErrorContains(err, fmt.Sprintf("HTTP status code not OK: STATUS CODE %d\nbody", t)) 95 | } 96 | } 97 | 98 | type testReadCloser struct { 99 | io.Reader 100 | isClosed bool 101 | } 102 | 103 | func (r *testReadCloser) Close() error { 104 | r.isClosed = true 105 | return nil 106 | } 107 | 108 | func Test_CloseResponse_OK(t *testing.T) { 109 | assert := assert.New(t) 110 | buf := strings.NewReader(`body`) 111 | body := &testReadCloser{Reader: buf} 112 | res := &http.Response{Body: body} 113 | util.CloseResponse(res) 114 | assert.True(body.isClosed) 115 | assert.Equal(0, buf.Len()) 116 | } 117 | 118 | func Test_CloseResponse_WithNil(t *testing.T) { 119 | util.CloseResponse(nil) 120 | } 121 | 122 | func Test_CloseResponse_WithBodyNil(t *testing.T) { 123 | res := &http.Response{Body: nil} 124 | util.CloseResponse(res) 125 | } 126 | 127 | func Test_ValuesFrom_Map(t *testing.T) { 128 | assert := assert.New(t) 129 | params := map[string]string{"foo": "bar", "zoo": "baz"} 130 | values, err := util.URLValuesFrom(params) 131 | assert.NoError(err) 132 | assert.Equal("foo=bar&zoo=baz", values.Encode()) 133 | } 134 | 135 | type testParams struct { 136 | Foo string `url:"foo"` 137 | Bar int `url:"bar"` 138 | Zoo string `url:"zoo,omitempty"` 139 | } 140 | 141 | func Test_ValuesFrom_Struct(t *testing.T) { 142 | assert := assert.New(t) 143 | params := &testParams{ 144 | Foo: "foo", 145 | Bar: 1, 146 | } 147 | values, err := util.URLValuesFrom(params) 148 | assert.NoError(err) 149 | assert.Equal("bar=1&foo=foo", values.Encode()) 150 | } 151 | 152 | func Test_ValuesFrom_Err(t *testing.T) { 153 | assert := assert.New(t) 154 | _, err := util.URLValuesFrom("xxx") 155 | assert.ErrorContains(err, "query: Values() expects struct input. Got string") 156 | } 157 | -------------------------------------------------------------------------------- /job.go: -------------------------------------------------------------------------------- 1 | //go:generate go run tools/withoutctx.go 2 | package redash 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | 8 | "github.com/winebarrel/redash-go/v2/internal/util" 9 | ) 10 | 11 | const ( 12 | // see https://redash.io/help/user-guide/integrations-and-api/api#Jobs 13 | JobStatusPending = 1 14 | JobStatusStarted = 2 15 | JobStatusSuccess = 3 16 | JobStatusFailure = 4 17 | JobStatusCancelled = 5 18 | ) 19 | 20 | type JobResponse struct { 21 | Job Job `json:"job"` 22 | } 23 | 24 | type Job struct { 25 | Error string `json:"error"` 26 | ID string `json:"id"` 27 | QueryResultID int `json:"query_result_id"` 28 | Status int `json:"status"` 29 | UpdatedAt any `json:"updated_at"` 30 | } 31 | 32 | func (client *Client) GetJob(ctx context.Context, id string) (*JobResponse, error) { 33 | res, close, err := client.Get(ctx, fmt.Sprintf("api/jobs/%s", id), nil) 34 | defer close() 35 | 36 | if err != nil { 37 | return nil, err 38 | } 39 | 40 | jobRes := &JobResponse{} 41 | 42 | if err := util.UnmarshalBody(res, &jobRes); err != nil { 43 | return nil, err 44 | } 45 | 46 | return jobRes, nil 47 | } 48 | -------------------------------------------------------------------------------- /job_test.go: -------------------------------------------------------------------------------- 1 | package redash_test 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/jarcoal/httpmock" 9 | "github.com/stretchr/testify/assert" 10 | "github.com/winebarrel/redash-go/v2" 11 | ) 12 | 13 | func Test_GetJob_OK(t *testing.T) { 14 | assert := assert.New(t) 15 | httpmock.Activate() 16 | defer httpmock.DeactivateAndReset() 17 | 18 | httpmock.RegisterResponder(http.MethodGet, "https://redash.example.com/api/jobs/623b290a-7fd9-4ea6-a2a6-96f9c9101f51", func(req *http.Request) (*http.Response, error) { 19 | assert.Equal( 20 | http.Header( 21 | http.Header{ 22 | "Authorization": []string{"Key " + testRedashAPIKey}, 23 | "Content-Type": []string{"application/json"}, 24 | "User-Agent": []string{"redash-go"}, 25 | }, 26 | ), 27 | req.Header, 28 | ) 29 | return httpmock.NewStringResponse(http.StatusOK, ` 30 | { 31 | "job": { 32 | "error": "", 33 | "id": "623b290a-7fd9-4ea6-a2a6-96f9c9101f51", 34 | "query_result_id": 1, 35 | "status": 3, 36 | "updated_at": 0 37 | } 38 | } 39 | `), nil 40 | }) 41 | 42 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 43 | res, err := client.GetJob(context.Background(), "623b290a-7fd9-4ea6-a2a6-96f9c9101f51") 44 | assert.NoError(err) 45 | assert.Equal(&redash.JobResponse{ 46 | Job: redash.Job{ 47 | Error: "", 48 | ID: "623b290a-7fd9-4ea6-a2a6-96f9c9101f51", 49 | QueryResultID: 1, 50 | Status: redash.JobStatusSuccess, 51 | UpdatedAt: float64(0), 52 | }, 53 | }, res) 54 | } 55 | 56 | func Test_GetJob_Err_5xx(t *testing.T) { 57 | assert := assert.New(t) 58 | httpmock.Activate() 59 | defer httpmock.DeactivateAndReset() 60 | 61 | httpmock.RegisterResponder(http.MethodGet, "https://redash.example.com/api/jobs/623b290a-7fd9-4ea6-a2a6-96f9c9101f51", func(req *http.Request) (*http.Response, error) { 62 | return httpmock.NewStringResponse(http.StatusServiceUnavailable, "error"), nil 63 | }) 64 | 65 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 66 | _, err := client.GetJob(context.Background(), "623b290a-7fd9-4ea6-a2a6-96f9c9101f51") 67 | assert.ErrorContains(err, "GET api/jobs/623b290a-7fd9-4ea6-a2a6-96f9c9101f51 failed: HTTP status code not OK: 503 Service Unavailable\nerror") 68 | } 69 | 70 | func Test_GetJob_IOErr(t *testing.T) { 71 | assert := assert.New(t) 72 | httpmock.Activate() 73 | defer httpmock.DeactivateAndReset() 74 | 75 | httpmock.RegisterResponder(http.MethodGet, "https://redash.example.com/api/jobs/623b290a-7fd9-4ea6-a2a6-96f9c9101f51", func(req *http.Request) (*http.Response, error) { 76 | return testIOErrResp, nil 77 | }) 78 | 79 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 80 | _, err := client.GetJob(context.Background(), "623b290a-7fd9-4ea6-a2a6-96f9c9101f51") 81 | assert.ErrorContains(err, "read response body failed: IO error") 82 | } 83 | -------------------------------------------------------------------------------- /job_without_ctx.go: -------------------------------------------------------------------------------- 1 | // Code generated from job.go using tools/withoutctx.go; DO NOT EDIT. 2 | 3 | package redash 4 | 5 | import "context" 6 | 7 | func (client *ClientWithoutContext) GetJob(id string) (*JobResponse, error) { 8 | return client.withCtx.GetJob(context.Background(), id) 9 | } 10 | -------------------------------------------------------------------------------- /organization.go: -------------------------------------------------------------------------------- 1 | //go:generate go run tools/withoutctx.go 2 | package redash 3 | 4 | import ( 5 | "context" 6 | 7 | "github.com/winebarrel/redash-go/v2/internal/util" 8 | ) 9 | 10 | type OrganizationStatus struct { 11 | ObjectCounters OrganizationStatusObjectCounters `json:"object_counters"` 12 | } 13 | 14 | type OrganizationStatusObjectCounters struct { 15 | Alerts int `json:"alerts"` 16 | Dashboards int `json:"dashboards"` 17 | DataSources int `json:"data_sources"` 18 | Queries int `json:"queries"` 19 | Users int `json:"users"` 20 | } 21 | 22 | func (client *Client) GetOrganizationStatus(ctx context.Context) (*OrganizationStatus, error) { 23 | res, close, err := client.Get(ctx, "api/organization/status", nil) 24 | defer close() 25 | 26 | if err != nil { 27 | return nil, err 28 | } 29 | 30 | status := &OrganizationStatus{} 31 | 32 | if err := util.UnmarshalBody(res, &status); err != nil { 33 | return nil, err 34 | } 35 | 36 | return status, nil 37 | } 38 | -------------------------------------------------------------------------------- /organization_test.go: -------------------------------------------------------------------------------- 1 | package redash_test 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/jarcoal/httpmock" 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | "github.com/winebarrel/redash-go/v2" 12 | ) 13 | 14 | func Test_GetOrganizationStatus_OK(t *testing.T) { 15 | assert := assert.New(t) 16 | httpmock.Activate() 17 | defer httpmock.DeactivateAndReset() 18 | 19 | httpmock.RegisterResponder(http.MethodGet, "https://redash.example.com/api/organization/status", func(req *http.Request) (*http.Response, error) { 20 | assert.Equal( 21 | http.Header( 22 | http.Header{ 23 | "Authorization": []string{"Key " + testRedashAPIKey}, 24 | "Content-Type": []string{"application/json"}, 25 | "User-Agent": []string{"redash-go"}, 26 | }, 27 | ), 28 | req.Header, 29 | ) 30 | return httpmock.NewStringResponse(http.StatusOK, ` 31 | { 32 | "object_counters": { 33 | "alerts": 1, 34 | "dashboards": 2, 35 | "data_sources": 3, 36 | "queries": 4, 37 | "users": 5 38 | } 39 | } 40 | `), nil 41 | }) 42 | 43 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 44 | res, err := client.GetOrganizationStatus(context.Background()) 45 | assert.NoError(err) 46 | assert.Equal(&redash.OrganizationStatus{ 47 | ObjectCounters: redash.OrganizationStatusObjectCounters{ 48 | Alerts: 1, 49 | Dashboards: 2, 50 | DataSources: 3, 51 | Queries: 4, 52 | Users: 5, 53 | }, 54 | }, res) 55 | } 56 | 57 | func Test_GetOrganizationStatus_Err_5xx(t *testing.T) { 58 | assert := assert.New(t) 59 | httpmock.Activate() 60 | defer httpmock.DeactivateAndReset() 61 | 62 | httpmock.RegisterResponder(http.MethodGet, "https://redash.example.com/api/organization/status", func(req *http.Request) (*http.Response, error) { 63 | return httpmock.NewStringResponse(http.StatusServiceUnavailable, "error"), nil 64 | }) 65 | 66 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 67 | _, err := client.GetOrganizationStatus(context.Background()) 68 | assert.ErrorContains(err, "GET api/organization/status failed: HTTP status code not OK: 503 Service Unavailable\nerror") 69 | } 70 | 71 | func Test_GetOrganizationStatus_IOErr(t *testing.T) { 72 | assert := assert.New(t) 73 | httpmock.Activate() 74 | defer httpmock.DeactivateAndReset() 75 | 76 | httpmock.RegisterResponder(http.MethodGet, "https://redash.example.com/api/organization/status", func(req *http.Request) (*http.Response, error) { 77 | return testIOErrResp, nil 78 | }) 79 | 80 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 81 | _, err := client.GetOrganizationStatus(context.Background()) 82 | assert.ErrorContains(err, "read response body failed: IO error") 83 | } 84 | 85 | func Test_GetOrganizationStatus_Acc(t *testing.T) { 86 | if !testAcc { 87 | t.Skip() 88 | } 89 | 90 | assert := assert.New(t) 91 | require := require.New(t) 92 | 93 | client, _ := redash.NewClient(testRedashEndpoint, testRedashAPIKey) 94 | status, err := client.GetOrganizationStatus(context.Background()) 95 | require.NoError(err) 96 | assert.NotNil(status) 97 | } 98 | -------------------------------------------------------------------------------- /organization_without_ctx.go: -------------------------------------------------------------------------------- 1 | // Code generated from organization.go using tools/withoutctx.go; DO NOT EDIT. 2 | 3 | package redash 4 | 5 | import "context" 6 | 7 | func (client *ClientWithoutContext) GetOrganizationStatus() (*OrganizationStatus, error) { 8 | return client.withCtx.GetOrganizationStatus(context.Background()) 9 | } 10 | -------------------------------------------------------------------------------- /ping.go: -------------------------------------------------------------------------------- 1 | //go:generate go run tools/withoutctx.go 2 | package redash 3 | 4 | import ( 5 | "bytes" 6 | "context" 7 | "fmt" 8 | "io" 9 | ) 10 | 11 | const ( 12 | pong = "PONG." 13 | ) 14 | 15 | func (client *Client) Ping(ctx context.Context) error { 16 | res, close, err := client.Get(ctx, "ping", nil) 17 | defer close() 18 | 19 | if err != nil { 20 | return err 21 | } 22 | 23 | body, err := io.ReadAll(res.Body) 24 | 25 | if err != nil { 26 | return err 27 | } 28 | 29 | if !bytes.Equal(body, []byte(pong)) { 30 | return fmt.Errorf("invalid ping response: %s", body) 31 | } 32 | 33 | return nil 34 | } 35 | -------------------------------------------------------------------------------- /ping_test.go: -------------------------------------------------------------------------------- 1 | package redash_test 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/jarcoal/httpmock" 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | "github.com/winebarrel/redash-go/v2" 12 | ) 13 | 14 | func Test_Ping_OK(t *testing.T) { 15 | assert := assert.New(t) 16 | httpmock.Activate() 17 | defer httpmock.DeactivateAndReset() 18 | 19 | httpmock.RegisterResponder(http.MethodGet, "https://redash.example.com/ping", func(req *http.Request) (*http.Response, error) { 20 | assert.Equal( 21 | http.Header( 22 | http.Header{ 23 | "Authorization": []string{"Key " + testRedashAPIKey}, 24 | "Content-Type": []string{"application/json"}, 25 | "User-Agent": []string{"redash-go"}, 26 | }, 27 | ), 28 | req.Header, 29 | ) 30 | return httpmock.NewStringResponse(http.StatusOK, `PONG.`), nil 31 | }) 32 | 33 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 34 | err := client.Ping(context.Background()) 35 | assert.NoError(err) 36 | } 37 | 38 | func Test_Ping_Err(t *testing.T) { 39 | assert := assert.New(t) 40 | httpmock.Activate() 41 | defer httpmock.DeactivateAndReset() 42 | 43 | httpmock.RegisterResponder(http.MethodGet, "https://redash.example.com/ping", func(req *http.Request) (*http.Response, error) { 44 | assert.Equal( 45 | http.Header( 46 | http.Header{ 47 | "Authorization": []string{"Key " + testRedashAPIKey}, 48 | "Content-Type": []string{"application/json"}, 49 | "User-Agent": []string{"redash-go"}, 50 | }, 51 | ), 52 | req.Header, 53 | ) 54 | return httpmock.NewStringResponse(http.StatusOK, ``), nil 55 | }) 56 | 57 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 58 | err := client.Ping(context.Background()) 59 | assert.ErrorContains(err, "invalid ping response: ") 60 | } 61 | 62 | func Test_Ping_Err_5xx(t *testing.T) { 63 | assert := assert.New(t) 64 | httpmock.Activate() 65 | defer httpmock.DeactivateAndReset() 66 | 67 | httpmock.RegisterResponder(http.MethodGet, "https://redash.example.com/ping", func(req *http.Request) (*http.Response, error) { 68 | return httpmock.NewStringResponse(http.StatusServiceUnavailable, `error`), nil 69 | }) 70 | 71 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 72 | err := client.Ping(context.Background()) 73 | assert.ErrorContains(err, "GET ping failed: HTTP status code not OK: 503 Service Unavailable\nerror") 74 | } 75 | 76 | func Test_Ping_IOErr(t *testing.T) { 77 | assert := assert.New(t) 78 | httpmock.Activate() 79 | defer httpmock.DeactivateAndReset() 80 | 81 | httpmock.RegisterResponder(http.MethodGet, "https://redash.example.com/ping", func(req *http.Request) (*http.Response, error) { 82 | return testIOErrResp, nil 83 | }) 84 | 85 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 86 | err := client.Ping(context.Background()) 87 | assert.ErrorContains(err, "IO error") 88 | } 89 | 90 | func Test_Ping_Acc(t *testing.T) { 91 | if !testAcc { 92 | t.Skip() 93 | } 94 | 95 | require := require.New(t) 96 | // NOTE: No authentication required 97 | client, _ := redash.NewClient(testRedashEndpoint, "") 98 | err := client.Ping(context.Background()) 99 | require.NoError(err) 100 | } 101 | -------------------------------------------------------------------------------- /ping_without_ctx.go: -------------------------------------------------------------------------------- 1 | // Code generated from ping.go using tools/withoutctx.go; DO NOT EDIT. 2 | 3 | package redash 4 | 5 | import "context" 6 | 7 | func (client *ClientWithoutContext) Ping() error { return client.withCtx.Ping(context.Background()) } 8 | -------------------------------------------------------------------------------- /query_snippet.go: -------------------------------------------------------------------------------- 1 | //go:generate go run tools/withoutctx.go 2 | package redash 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | "time" 8 | 9 | "github.com/winebarrel/redash-go/v2/internal/util" 10 | ) 11 | 12 | type QuerySnippet struct { 13 | CreatedAt time.Time `json:"created_at"` 14 | Description string `json:"description"` 15 | ID int `json:"id"` 16 | Snippet string `json:"snippet"` 17 | Trigger string `json:"trigger"` 18 | UpdatedAt time.Time `json:"updated_at"` 19 | User User `json:"user"` 20 | } 21 | 22 | func (client *Client) ListQuerySnippets(ctx context.Context) ([]QuerySnippet, error) { 23 | res, close, err := client.Get(ctx, "api/query_snippets", nil) 24 | defer close() 25 | 26 | if err != nil { 27 | return nil, err 28 | } 29 | 30 | querySnippets := []QuerySnippet{} 31 | 32 | if err := util.UnmarshalBody(res, &querySnippets); err != nil { 33 | return nil, err 34 | } 35 | 36 | return querySnippets, nil 37 | } 38 | 39 | func (client *Client) GetQuerySnippet(ctx context.Context, id int) (*QuerySnippet, error) { 40 | res, close, err := client.Get(ctx, fmt.Sprintf("api/query_snippets/%d", id), nil) 41 | defer close() 42 | 43 | if err != nil { 44 | return nil, err 45 | } 46 | 47 | querySnippet := &QuerySnippet{} 48 | 49 | if err := util.UnmarshalBody(res, &querySnippet); err != nil { 50 | return nil, err 51 | } 52 | 53 | return querySnippet, nil 54 | } 55 | 56 | type CreateQuerySnippetInput struct { 57 | Description string `json:"description"` 58 | Snippet string `json:"snippet"` 59 | Trigger string `json:"trigger"` 60 | } 61 | 62 | func (client *Client) CreateQuerySnippet(ctx context.Context, input *CreateQuerySnippetInput) (*QuerySnippet, error) { 63 | res, close, err := client.Post(ctx, "api/query_snippets", input) 64 | defer close() 65 | 66 | if err != nil { 67 | return nil, err 68 | } 69 | 70 | querySnippet := &QuerySnippet{} 71 | 72 | if err := util.UnmarshalBody(res, &querySnippet); err != nil { 73 | return nil, err 74 | } 75 | 76 | return querySnippet, nil 77 | } 78 | 79 | type UpdateQuerySnippetInput struct { 80 | Description string `json:"description,omitempty"` 81 | Snippet string `json:"snippet,omitempty"` 82 | Trigger string `json:"trigger,omitempty"` 83 | } 84 | 85 | func (client *Client) UpdateQuerySnippet(ctx context.Context, id int, input *UpdateQuerySnippetInput) (*QuerySnippet, error) { 86 | res, close, err := client.Post(ctx, fmt.Sprintf("api/query_snippets/%d", id), input) 87 | defer close() 88 | 89 | if err != nil { 90 | return nil, err 91 | } 92 | 93 | querySnippet := &QuerySnippet{} 94 | 95 | if err := util.UnmarshalBody(res, &querySnippet); err != nil { 96 | return nil, err 97 | } 98 | 99 | return querySnippet, nil 100 | } 101 | 102 | func (client *Client) DeleteQuerySnippet(ctx context.Context, id int) error { 103 | _, close, err := client.Delete(ctx, fmt.Sprintf("api/query_snippets/%d", id)) 104 | defer close() 105 | 106 | if err != nil { 107 | return err 108 | } 109 | 110 | return nil 111 | } 112 | -------------------------------------------------------------------------------- /query_snippet_test.go: -------------------------------------------------------------------------------- 1 | package redash_test 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net/http" 7 | "testing" 8 | 9 | "github.com/araddon/dateparse" 10 | "github.com/jarcoal/httpmock" 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | "github.com/winebarrel/redash-go/v2" 14 | ) 15 | 16 | func Test_ListQuerySnippets_OK(t *testing.T) { 17 | assert := assert.New(t) 18 | httpmock.Activate() 19 | defer httpmock.DeactivateAndReset() 20 | 21 | httpmock.RegisterResponder(http.MethodGet, "https://redash.example.com/api/query_snippets", func(req *http.Request) (*http.Response, error) { 22 | assert.Equal( 23 | http.Header( 24 | http.Header{ 25 | "Authorization": []string{"Key " + testRedashAPIKey}, 26 | "Content-Type": []string{"application/json"}, 27 | "User-Agent": []string{"redash-go"}, 28 | }, 29 | ), 30 | req.Header, 31 | ) 32 | return httpmock.NewStringResponse(http.StatusOK, ` 33 | [ 34 | { 35 | "created_at": "2023-02-10T01:23:45.000Z", 36 | "description": "description", 37 | "id": 1, 38 | "snippet": "select 1", 39 | "trigger": "my-snippet", 40 | "updated_at": "2023-02-10T01:23:45.000Z", 41 | "user": {} 42 | } 43 | ] 44 | `), nil 45 | }) 46 | 47 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 48 | res, err := client.ListQuerySnippets(context.Background()) 49 | assert.NoError(err) 50 | assert.Equal([]redash.QuerySnippet{ 51 | { 52 | CreatedAt: dateparse.MustParse("2023-02-10T01:23:45.000Z"), 53 | Description: "description", 54 | ID: 1, 55 | Snippet: "select 1", 56 | Trigger: "my-snippet", 57 | UpdatedAt: dateparse.MustParse("2023-02-10T01:23:45.000Z"), 58 | User: redash.User{}, 59 | }, 60 | }, res) 61 | } 62 | 63 | func Test_ListQuerySnippets_Err_5xx(t *testing.T) { 64 | assert := assert.New(t) 65 | httpmock.Activate() 66 | defer httpmock.DeactivateAndReset() 67 | 68 | httpmock.RegisterResponder(http.MethodGet, "https://redash.example.com/api/query_snippets", func(req *http.Request) (*http.Response, error) { 69 | return httpmock.NewStringResponse(http.StatusServiceUnavailable, "error"), nil 70 | }) 71 | 72 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 73 | _, err := client.ListQuerySnippets(context.Background()) 74 | assert.ErrorContains(err, "GET api/query_snippets failed: HTTP status code not OK: 503 Service Unavailable\nerror") 75 | } 76 | 77 | func Test_ListQuerySnippets_IOErr(t *testing.T) { 78 | assert := assert.New(t) 79 | httpmock.Activate() 80 | defer httpmock.DeactivateAndReset() 81 | 82 | httpmock.RegisterResponder(http.MethodGet, "https://redash.example.com/api/query_snippets", func(req *http.Request) (*http.Response, error) { 83 | return testIOErrResp, nil 84 | }) 85 | 86 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 87 | _, err := client.ListQuerySnippets(context.Background()) 88 | assert.ErrorContains(err, "read response body failed: IO error") 89 | } 90 | 91 | func Test_GetQuerySnippets_OK(t *testing.T) { 92 | assert := assert.New(t) 93 | httpmock.Activate() 94 | defer httpmock.DeactivateAndReset() 95 | 96 | httpmock.RegisterResponder(http.MethodGet, "https://redash.example.com/api/query_snippets/1", func(req *http.Request) (*http.Response, error) { 97 | assert.Equal( 98 | http.Header( 99 | http.Header{ 100 | "Authorization": []string{"Key " + testRedashAPIKey}, 101 | "Content-Type": []string{"application/json"}, 102 | "User-Agent": []string{"redash-go"}, 103 | }, 104 | ), 105 | req.Header, 106 | ) 107 | return httpmock.NewStringResponse(http.StatusOK, ` 108 | { 109 | "created_at": "2023-02-10T01:23:45.000Z", 110 | "description": "description", 111 | "id": 1, 112 | "snippet": "select 1", 113 | "trigger": "my-snippet", 114 | "updated_at": "2023-02-10T01:23:45.000Z", 115 | "user": {} 116 | } 117 | `), nil 118 | }) 119 | 120 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 121 | res, err := client.GetQuerySnippet(context.Background(), 1) 122 | assert.NoError(err) 123 | assert.Equal(&redash.QuerySnippet{ 124 | CreatedAt: dateparse.MustParse("2023-02-10T01:23:45.000Z"), 125 | Description: "description", 126 | ID: 1, 127 | Snippet: "select 1", 128 | Trigger: "my-snippet", 129 | UpdatedAt: dateparse.MustParse("2023-02-10T01:23:45.000Z"), 130 | User: redash.User{}, 131 | }, res) 132 | } 133 | 134 | func Test_GetQuerySnippets_Err_5xx(t *testing.T) { 135 | assert := assert.New(t) 136 | httpmock.Activate() 137 | defer httpmock.DeactivateAndReset() 138 | 139 | httpmock.RegisterResponder(http.MethodGet, "https://redash.example.com/api/query_snippets/1", func(req *http.Request) (*http.Response, error) { 140 | return httpmock.NewStringResponse(http.StatusServiceUnavailable, "error"), nil 141 | }) 142 | 143 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 144 | _, err := client.GetQuerySnippet(context.Background(), 1) 145 | assert.ErrorContains(err, "GET api/query_snippets/1 failed: HTTP status code not OK: 503 Service Unavailable\nerror") 146 | } 147 | 148 | func Test_GetQuerySnippets_IOErr(t *testing.T) { 149 | assert := assert.New(t) 150 | httpmock.Activate() 151 | defer httpmock.DeactivateAndReset() 152 | 153 | httpmock.RegisterResponder(http.MethodGet, "https://redash.example.com/api/query_snippets/1", func(req *http.Request) (*http.Response, error) { 154 | return testIOErrResp, nil 155 | }) 156 | 157 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 158 | _, err := client.GetQuerySnippet(context.Background(), 1) 159 | assert.ErrorContains(err, "read response body failed: IO error") 160 | } 161 | 162 | func Test_CreateQuerySnippets_OK(t *testing.T) { 163 | assert := assert.New(t) 164 | httpmock.Activate() 165 | defer httpmock.DeactivateAndReset() 166 | 167 | httpmock.RegisterResponder(http.MethodPost, "https://redash.example.com/api/query_snippets", func(req *http.Request) (*http.Response, error) { 168 | assert.Equal( 169 | http.Header( 170 | http.Header{ 171 | "Authorization": []string{"Key " + testRedashAPIKey}, 172 | "Content-Type": []string{"application/json"}, 173 | "User-Agent": []string{"redash-go"}, 174 | }, 175 | ), 176 | req.Header, 177 | ) 178 | if req.Body == nil { 179 | assert.FailNow("req.Body is nil") 180 | } 181 | body, _ := io.ReadAll(req.Body) 182 | assert.Equal(`{"description":"description","snippet":"select 1","trigger":"my-snippet"}`, string(body)) 183 | return httpmock.NewStringResponse(http.StatusOK, ` 184 | { 185 | "created_at": "2023-02-10T01:23:45.000Z", 186 | "description": "description", 187 | "id": 1, 188 | "snippet": "select 1", 189 | "trigger": "my-snippet", 190 | "updated_at": "2023-02-10T01:23:45.000Z", 191 | "user": {} 192 | } 193 | `), nil 194 | }) 195 | 196 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 197 | res, err := client.CreateQuerySnippet(context.Background(), &redash.CreateQuerySnippetInput{ 198 | Description: "description", 199 | Snippet: "select 1", 200 | Trigger: "my-snippet", 201 | }) 202 | assert.NoError(err) 203 | assert.Equal(&redash.QuerySnippet{ 204 | CreatedAt: dateparse.MustParse("2023-02-10T01:23:45.000Z"), 205 | Description: "description", 206 | ID: 1, 207 | Snippet: "select 1", 208 | Trigger: "my-snippet", 209 | UpdatedAt: dateparse.MustParse("2023-02-10T01:23:45.000Z"), 210 | User: redash.User{}, 211 | }, res) 212 | } 213 | 214 | func Test_CreateQuerySnippets_Err_5xx(t *testing.T) { 215 | assert := assert.New(t) 216 | httpmock.Activate() 217 | defer httpmock.DeactivateAndReset() 218 | 219 | httpmock.RegisterResponder(http.MethodPost, "https://redash.example.com/api/query_snippets", func(req *http.Request) (*http.Response, error) { 220 | return httpmock.NewStringResponse(http.StatusServiceUnavailable, "error"), nil 221 | }) 222 | 223 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 224 | _, err := client.CreateQuerySnippet(context.Background(), &redash.CreateQuerySnippetInput{ 225 | Description: "description", 226 | Snippet: "select 1", 227 | Trigger: "my-snippet", 228 | }) 229 | assert.ErrorContains(err, "POST api/query_snippets failed: HTTP status code not OK: 503 Service Unavailable\nerror") 230 | } 231 | 232 | func Test_CreateQuerySnippets_IOErr(t *testing.T) { 233 | assert := assert.New(t) 234 | httpmock.Activate() 235 | defer httpmock.DeactivateAndReset() 236 | 237 | httpmock.RegisterResponder(http.MethodPost, "https://redash.example.com/api/query_snippets", func(req *http.Request) (*http.Response, error) { 238 | return testIOErrResp, nil 239 | }) 240 | 241 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 242 | _, err := client.CreateQuerySnippet(context.Background(), &redash.CreateQuerySnippetInput{ 243 | Description: "description", 244 | Snippet: "select 1", 245 | Trigger: "my-snippet", 246 | }) 247 | assert.ErrorContains(err, "read response body failed: IO error") 248 | } 249 | 250 | func Test_UpdateQuerySnippets_OK(t *testing.T) { 251 | assert := assert.New(t) 252 | httpmock.Activate() 253 | defer httpmock.DeactivateAndReset() 254 | 255 | httpmock.RegisterResponder(http.MethodPost, "https://redash.example.com/api/query_snippets/1", func(req *http.Request) (*http.Response, error) { 256 | assert.Equal( 257 | http.Header( 258 | http.Header{ 259 | "Authorization": []string{"Key " + testRedashAPIKey}, 260 | "Content-Type": []string{"application/json"}, 261 | "User-Agent": []string{"redash-go"}, 262 | }, 263 | ), 264 | req.Header, 265 | ) 266 | if req.Body == nil { 267 | assert.FailNow("req.Body is nil") 268 | } 269 | body, _ := io.ReadAll(req.Body) 270 | assert.Equal(`{"description":"description","snippet":"select 1","trigger":"my-snippet"}`, string(body)) 271 | return httpmock.NewStringResponse(http.StatusOK, ` 272 | { 273 | "created_at": "2023-02-10T01:23:45.000Z", 274 | "description": "description", 275 | "id": 1, 276 | "snippet": "select 1", 277 | "trigger": "my-snippet", 278 | "updated_at": "2023-02-10T01:23:45.000Z", 279 | "user": {} 280 | } 281 | `), nil 282 | }) 283 | 284 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 285 | res, err := client.UpdateQuerySnippet(context.Background(), 1, &redash.UpdateQuerySnippetInput{ 286 | Description: "description", 287 | Snippet: "select 1", 288 | Trigger: "my-snippet", 289 | }) 290 | assert.NoError(err) 291 | assert.Equal(&redash.QuerySnippet{ 292 | CreatedAt: dateparse.MustParse("2023-02-10T01:23:45.000Z"), 293 | Description: "description", 294 | ID: 1, 295 | Snippet: "select 1", 296 | Trigger: "my-snippet", 297 | UpdatedAt: dateparse.MustParse("2023-02-10T01:23:45.000Z"), 298 | User: redash.User{}, 299 | }, res) 300 | } 301 | 302 | func Test_UpdateQuerySnippets_Err_5xx(t *testing.T) { 303 | assert := assert.New(t) 304 | httpmock.Activate() 305 | defer httpmock.DeactivateAndReset() 306 | 307 | httpmock.RegisterResponder(http.MethodPost, "https://redash.example.com/api/query_snippets/1", func(req *http.Request) (*http.Response, error) { 308 | return httpmock.NewStringResponse(http.StatusServiceUnavailable, "error"), nil 309 | }) 310 | 311 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 312 | _, err := client.UpdateQuerySnippet(context.Background(), 1, &redash.UpdateQuerySnippetInput{ 313 | Description: "description", 314 | Snippet: "select 1", 315 | Trigger: "my-snippet", 316 | }) 317 | assert.ErrorContains(err, "POST api/query_snippets/1 failed: HTTP status code not OK: 503 Service Unavailable\nerror") 318 | } 319 | 320 | func Test_UpdateQuerySnippets_IOErr(t *testing.T) { 321 | assert := assert.New(t) 322 | httpmock.Activate() 323 | defer httpmock.DeactivateAndReset() 324 | 325 | httpmock.RegisterResponder(http.MethodPost, "https://redash.example.com/api/query_snippets/1", func(req *http.Request) (*http.Response, error) { 326 | return testIOErrResp, nil 327 | }) 328 | 329 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 330 | _, err := client.UpdateQuerySnippet(context.Background(), 1, &redash.UpdateQuerySnippetInput{ 331 | Description: "description", 332 | Snippet: "select 1", 333 | Trigger: "my-snippet", 334 | }) 335 | assert.ErrorContains(err, "read response body failed: IO error") 336 | } 337 | 338 | func Test_DeleteQuerySnippets_OK(t *testing.T) { 339 | assert := assert.New(t) 340 | httpmock.Activate() 341 | defer httpmock.DeactivateAndReset() 342 | 343 | httpmock.RegisterResponder(http.MethodDelete, "https://redash.example.com/api/query_snippets/1", func(req *http.Request) (*http.Response, error) { 344 | assert.Equal( 345 | http.Header( 346 | http.Header{ 347 | "Authorization": []string{"Key " + testRedashAPIKey}, 348 | "Content-Type": []string{"application/json"}, 349 | "User-Agent": []string{"redash-go"}, 350 | }, 351 | ), 352 | req.Header, 353 | ) 354 | return httpmock.NewStringResponse(http.StatusOK, ` 355 | { 356 | "created_at": "2023-02-10T01:23:45.000Z", 357 | "description": "description", 358 | "id": 1, 359 | "snippet": "select 1", 360 | "trigger": "my-snippet", 361 | "updated_at": "2023-02-10T01:23:45.000Z", 362 | "user": {} 363 | } 364 | `), nil 365 | }) 366 | 367 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 368 | err := client.DeleteQuerySnippet(context.Background(), 1) 369 | assert.NoError(err) 370 | } 371 | 372 | func Test_DeleteQuerySnippets_Err_5xx(t *testing.T) { 373 | assert := assert.New(t) 374 | httpmock.Activate() 375 | defer httpmock.DeactivateAndReset() 376 | 377 | httpmock.RegisterResponder(http.MethodDelete, "https://redash.example.com/api/query_snippets/1", func(req *http.Request) (*http.Response, error) { 378 | return httpmock.NewStringResponse(http.StatusServiceUnavailable, "error"), nil 379 | }) 380 | 381 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 382 | err := client.DeleteQuerySnippet(context.Background(), 1) 383 | assert.ErrorContains(err, "DELETE api/query_snippets/1 failed: HTTP status code not OK: 503 Service Unavailable\nerror") 384 | } 385 | 386 | func Test_QuerySnippet_Acc(t *testing.T) { 387 | if !testAcc { 388 | t.Skip() 389 | } 390 | 391 | assert := assert.New(t) 392 | require := require.New(t) 393 | client, _ := redash.NewClient(testRedashEndpoint, testRedashAPIKey) 394 | 395 | _, err := client.ListQuerySnippets(context.Background()) 396 | require.NoError(err) 397 | 398 | snippet, err := client.CreateQuerySnippet(context.Background(), &redash.CreateQuerySnippetInput{ 399 | Description: "description", 400 | Snippet: "select 1", 401 | Trigger: "my-snippet-1", 402 | }) 403 | require.NoError(err) 404 | assert.Equal("my-snippet-1", snippet.Trigger) 405 | 406 | snippet, err = client.GetQuerySnippet(context.Background(), snippet.ID) 407 | require.NoError(err) 408 | assert.Equal("my-snippet-1", snippet.Trigger) 409 | 410 | snippet, err = client.UpdateQuerySnippet(context.Background(), snippet.ID, &redash.UpdateQuerySnippetInput{ 411 | Snippet: "select 2", 412 | }) 413 | require.NoError(err) 414 | assert.Equal("select 2", snippet.Snippet) 415 | 416 | err = client.DeleteQuerySnippet(context.Background(), snippet.ID) 417 | require.NoError(err) 418 | 419 | _, err = client.GetAlert(context.Background(), snippet.ID) 420 | assert.Error(err) 421 | } 422 | -------------------------------------------------------------------------------- /query_snippet_without_ctx.go: -------------------------------------------------------------------------------- 1 | // Code generated from query_snippet.go using tools/withoutctx.go; DO NOT EDIT. 2 | 3 | package redash 4 | 5 | import "context" 6 | 7 | func (client *ClientWithoutContext) ListQuerySnippets() ([]QuerySnippet, error) { 8 | return client.withCtx.ListQuerySnippets(context.Background()) 9 | } 10 | 11 | func (client *ClientWithoutContext) GetQuerySnippet(id int) (*QuerySnippet, error) { 12 | return client.withCtx.GetQuerySnippet(context.Background(), id) 13 | } 14 | 15 | func (client *ClientWithoutContext) CreateQuerySnippet(input *CreateQuerySnippetInput) (*QuerySnippet, error) { 16 | return client.withCtx.CreateQuerySnippet(context.Background(), input) 17 | } 18 | 19 | func (client *ClientWithoutContext) UpdateQuerySnippet(id int, input *UpdateQuerySnippetInput) (*QuerySnippet, error) { 20 | return client.withCtx.UpdateQuerySnippet(context.Background(), id, input) 21 | } 22 | 23 | func (client *ClientWithoutContext) DeleteQuerySnippet(id int) error { 24 | return client.withCtx.DeleteQuerySnippet(context.Background(), id) 25 | } 26 | -------------------------------------------------------------------------------- /query_without_ctx.go: -------------------------------------------------------------------------------- 1 | // Code generated from query.go using tools/withoutctx.go; DO NOT EDIT. 2 | 3 | package redash 4 | 5 | import ( 6 | "bytes" 7 | "context" 8 | "io" 9 | ) 10 | 11 | func (client *ClientWithoutContext) ListQueries(input *ListQueriesInput) (*QueryPage, error) { 12 | return client.withCtx.ListQueries(context.Background(), input) 13 | } 14 | 15 | func (client *ClientWithoutContext) GetQuery(id int) (*Query, error) { 16 | return client.withCtx.GetQuery(context.Background(), id) 17 | } 18 | 19 | func (client *ClientWithoutContext) CreateFavoriteQuery(id int) error { 20 | return client.withCtx.CreateFavoriteQuery(context.Background(), id) 21 | } 22 | 23 | func (client *ClientWithoutContext) CreateQuery(input *CreateQueryInput) (*Query, error) { 24 | return client.withCtx.CreateQuery(context.Background(), input) 25 | } 26 | 27 | func (client *ClientWithoutContext) ForkQuery(id int) (*Query, error) { 28 | return client.withCtx.ForkQuery(context.Background(), id) 29 | } 30 | 31 | func (client *ClientWithoutContext) UpdateQuery(id int, input *UpdateQueryInput) (*Query, error) { 32 | return client.withCtx.UpdateQuery(context.Background(), id, input) 33 | } 34 | 35 | func (client *ClientWithoutContext) PublishQuery(id int) error { 36 | return client.withCtx.PublishQuery(context.Background(), id) 37 | } 38 | 39 | func (client *ClientWithoutContext) UnpublishQuery(id int) error { 40 | return client.withCtx.UnpublishQuery(context.Background(), id) 41 | } 42 | 43 | func (client *ClientWithoutContext) ArchiveQuery(id int) error { 44 | return client.withCtx.ArchiveQuery(context.Background(), id) 45 | } 46 | 47 | func (client *ClientWithoutContext) GetQueryResultsJSON(id int, out io.Writer) error { 48 | return client.withCtx.GetQueryResultsJSON(context.Background(), id, out) 49 | } 50 | 51 | func (client *ClientWithoutContext) GetQueryResultsStruct(id int) (*GetQueryResultsOutput, error) { 52 | return client.withCtx.GetQueryResultsStruct(context.Background(), id) 53 | } 54 | 55 | func (client *ClientWithoutContext) GetQueryResultsCSV(id int, out io.Writer) error { 56 | return client.withCtx.GetQueryResultsCSV(context.Background(), id, out) 57 | } 58 | 59 | func (client *ClientWithoutContext) GetQueryResults(id int, ext string, out io.Writer) error { 60 | return client.withCtx.GetQueryResults(context.Background(), id, ext, out) 61 | } 62 | 63 | func (client *ClientWithoutContext) GetQueryResultByID(queryResultId int, ext string, out *bytes.Buffer) error { 64 | return client.withCtx.GetQueryResultByID(context.Background(), queryResultId, ext, out) 65 | } 66 | 67 | func (client *ClientWithoutContext) ExecQueryJSON(id int, input *ExecQueryJSONInput, out io.Writer) (*JobResponse, error) { 68 | return client.withCtx.ExecQueryJSON(context.Background(), id, input, out) 69 | } 70 | 71 | func (client *ClientWithoutContext) WaitQueryJSON(queryId int, job *JobResponse, option *WaitQueryJSONOption, out io.Writer) error { 72 | return client.withCtx.WaitQueryJSON(context.Background(), queryId, job, option, out) 73 | } 74 | 75 | func (client *ClientWithoutContext) WaitQueryStruct(queryId int, job *JobResponse, option *WaitQueryJSONOption, buf *bytes.Buffer) (*GetQueryResultsOutput, error) { 76 | return client.withCtx.WaitQueryStruct(context.Background(), queryId, job, option, buf) 77 | } 78 | 79 | func (client *ClientWithoutContext) GetQueryTags() (*QueryTags, error) { 80 | return client.withCtx.GetQueryTags(context.Background()) 81 | } 82 | 83 | func (client *ClientWithoutContext) RefreshQuery(id int, input *RefreshQueryInput) (*JobResponse, error) { 84 | return client.withCtx.RefreshQuery(context.Background(), id, input) 85 | } 86 | 87 | func (client *ClientWithoutContext) SearchQueries(input *SearchQueriesInput) (*QueryPage, error) { 88 | return client.withCtx.SearchQueries(context.Background(), input) 89 | } 90 | 91 | func (client *ClientWithoutContext) ListMyQueries(input *ListMyQueriesInput) (*QueryPage, error) { 92 | return client.withCtx.ListMyQueries(context.Background(), input) 93 | } 94 | 95 | func (client *ClientWithoutContext) ListFavoriteQueries(input *ListFavoriteQueriesInput) (*QueryPage, error) { 96 | return client.withCtx.ListFavoriteQueries(context.Background(), input) 97 | } 98 | 99 | func (client *ClientWithoutContext) FormatQuery(query string) (*FormatQueryOutput, error) { 100 | return client.withCtx.FormatQuery(context.Background(), query) 101 | } 102 | 103 | func (client *ClientWithoutContext) ListRecentQueries() ([]Query, error) { 104 | return client.withCtx.ListRecentQueries(context.Background()) 105 | } 106 | -------------------------------------------------------------------------------- /redash_go_test.go: -------------------------------------------------------------------------------- 1 | package redash_test 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "net/http" 7 | "os" 8 | "strconv" 9 | "testing" 10 | "testing/iotest" 11 | ) 12 | 13 | var ( 14 | testAcc = false 15 | testIOErrResp = &http.Response{ 16 | Status: strconv.Itoa(http.StatusOK), 17 | StatusCode: http.StatusOK, 18 | Body: io.NopCloser(iotest.ErrReader(errors.New("IO error"))), 19 | } 20 | ) 21 | 22 | const ( 23 | testRedashEndpoint = "http://localhost:5001" 24 | testRedashAPIKey = "6nh64ZsT66WeVJvNZ6WB5D2JKZULeC2VBdSD68wt" 25 | ) 26 | 27 | func TestMain(m *testing.M) { 28 | if v := os.Getenv("TEST_ACC"); v == "1" { 29 | testAcc = true 30 | } 31 | 32 | m.Run() 33 | } 34 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended", 5 | ":disableDependencyDashboard" 6 | ], 7 | "postUpdateOptions": [ 8 | "gomodTidy" 9 | ], 10 | "vulnerabilityAlerts": { 11 | "enabled": true 12 | }, 13 | "packageRules": [ 14 | { 15 | "matchPackageNames": [ 16 | "pgautoupgrade/pgautoupgrade" 17 | ], 18 | "matchManagers": [ 19 | "docker-compose" 20 | ], 21 | "enabled": false 22 | }, 23 | { 24 | "matchPackageNames": [ 25 | "github.com/winebarrel/redash-go/*" 26 | ], 27 | "matchManagers": [ 28 | "gomod" 29 | ], 30 | "enabled": false 31 | } 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /session.go: -------------------------------------------------------------------------------- 1 | //go:generate go run tools/withoutctx.go 2 | package redash 3 | 4 | import ( 5 | "context" 6 | 7 | "github.com/winebarrel/redash-go/v2/internal/util" 8 | ) 9 | 10 | type Session struct { 11 | ClientConfig ClientConfig `json:"client_config"` 12 | Messages []string `json:"messages"` 13 | OrgSlug string `json:"org_slug"` 14 | User User `json:"user"` 15 | } 16 | 17 | func (client *Client) TestCredentials(ctx context.Context) error { 18 | _, close, err := client.Get(ctx, "api/session", nil) 19 | defer close() 20 | 21 | if err != nil { 22 | return err 23 | } 24 | 25 | return nil 26 | } 27 | 28 | func (client *Client) GetSession(ctx context.Context) (*Session, error) { 29 | res, close, err := client.Get(ctx, "api/session", nil) 30 | defer close() 31 | 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | session := &Session{} 37 | 38 | if err := util.UnmarshalBody(res, &session); err != nil { 39 | return nil, err 40 | } 41 | 42 | return session, nil 43 | } 44 | -------------------------------------------------------------------------------- /session_test.go: -------------------------------------------------------------------------------- 1 | package redash_test 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/jarcoal/httpmock" 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | "github.com/winebarrel/redash-go/v2" 12 | ) 13 | 14 | func Test_TestCredentials_OK(t *testing.T) { 15 | assert := assert.New(t) 16 | httpmock.Activate() 17 | defer httpmock.DeactivateAndReset() 18 | 19 | httpmock.RegisterResponder(http.MethodGet, "https://redash.example.com/api/session", func(req *http.Request) (*http.Response, error) { 20 | assert.Equal( 21 | http.Header( 22 | http.Header{ 23 | "Authorization": []string{"Key " + testRedashAPIKey}, 24 | "Content-Type": []string{"application/json"}, 25 | "User-Agent": []string{"redash-go"}, 26 | }, 27 | ), 28 | req.Header, 29 | ) 30 | return httpmock.NewStringResponse(http.StatusOK, `{}`), nil 31 | }) 32 | 33 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 34 | err := client.TestCredentials(context.Background()) 35 | assert.NoError(err) 36 | } 37 | 38 | func Test_TestCredentials_Err(t *testing.T) { 39 | assert := assert.New(t) 40 | httpmock.Activate() 41 | defer httpmock.DeactivateAndReset() 42 | 43 | httpmock.RegisterResponder(http.MethodGet, "https://redash.example.com/api/session", func(req *http.Request) (*http.Response, error) { 44 | return httpmock.NewStringResponse(http.StatusNotFound, `{"message": "Couldn't find resource. Please login and try again."}`), nil 45 | }) 46 | 47 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 48 | err := client.TestCredentials(context.Background()) 49 | assert.ErrorContains(err, "GET api/session failed: HTTP status code not OK: 404") 50 | } 51 | 52 | func Test_TestCredentials_Acc(t *testing.T) { 53 | if !testAcc { 54 | t.Skip() 55 | } 56 | 57 | assert := assert.New(t) 58 | client, _ := redash.NewClient(testRedashEndpoint, testRedashAPIKey) 59 | err := client.TestCredentials(context.Background()) 60 | assert.NoError(err) 61 | } 62 | 63 | func Test_GetSession_OK(t *testing.T) { 64 | assert := assert.New(t) 65 | httpmock.Activate() 66 | defer httpmock.DeactivateAndReset() 67 | 68 | httpmock.RegisterResponder(http.MethodGet, "https://redash.example.com/api/session", func(req *http.Request) (*http.Response, error) { 69 | assert.Equal( 70 | http.Header( 71 | http.Header{ 72 | "Authorization": []string{"Key " + testRedashAPIKey}, 73 | "Content-Type": []string{"application/json"}, 74 | "User-Agent": []string{"redash-go"}, 75 | }, 76 | ), 77 | req.Header, 78 | ) 79 | return httpmock.NewStringResponse(http.StatusOK, ` 80 | { 81 | "client_config": {}, 82 | "messages": [ 83 | "email-not-verified", 84 | "using-deprecated-embed-feature" 85 | ], 86 | "org_slug": "default", 87 | "user": {} 88 | } 89 | `), nil 90 | }) 91 | 92 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 93 | res, err := client.GetSession(context.Background()) 94 | assert.NoError(err) 95 | assert.Equal(&redash.Session{ 96 | ClientConfig: redash.ClientConfig{}, 97 | Messages: []string{ 98 | "email-not-verified", 99 | "using-deprecated-embed-feature", 100 | }, 101 | OrgSlug: "default", 102 | User: redash.User{}, 103 | }, res) 104 | } 105 | 106 | func Test_GetSession_Err_5xx(t *testing.T) { 107 | assert := assert.New(t) 108 | httpmock.Activate() 109 | defer httpmock.DeactivateAndReset() 110 | 111 | httpmock.RegisterResponder(http.MethodGet, "https://redash.example.com/api/session", func(req *http.Request) (*http.Response, error) { 112 | return httpmock.NewStringResponse(http.StatusServiceUnavailable, "error"), nil 113 | }) 114 | 115 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 116 | _, err := client.GetSession(context.Background()) 117 | assert.ErrorContains(err, "GET api/session failed: HTTP status code not OK: 503 Service Unavailable\nerror") 118 | } 119 | 120 | func Test_GetSession_IOErr(t *testing.T) { 121 | assert := assert.New(t) 122 | httpmock.Activate() 123 | defer httpmock.DeactivateAndReset() 124 | 125 | httpmock.RegisterResponder(http.MethodGet, "https://redash.example.com/api/session", func(req *http.Request) (*http.Response, error) { 126 | return testIOErrResp, nil 127 | }) 128 | 129 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 130 | _, err := client.GetSession(context.Background()) 131 | assert.ErrorContains(err, "read response body failed: IO error") 132 | } 133 | 134 | func Test_Session_Acc(t *testing.T) { 135 | if !testAcc { 136 | t.Skip() 137 | } 138 | 139 | assert := assert.New(t) 140 | require := require.New(t) 141 | client, _ := redash.NewClient(testRedashEndpoint, testRedashAPIKey) 142 | session, err := client.GetSession(context.Background()) 143 | require.NoError(err) 144 | assert.Equal("admin", session.User.Name) 145 | } 146 | -------------------------------------------------------------------------------- /session_without_ctx.go: -------------------------------------------------------------------------------- 1 | // Code generated from session.go using tools/withoutctx.go; DO NOT EDIT. 2 | 3 | package redash 4 | 5 | import "context" 6 | 7 | func (client *ClientWithoutContext) TestCredentials() error { 8 | return client.withCtx.TestCredentials(context.Background()) 9 | } 10 | 11 | func (client *ClientWithoutContext) GetSession() (*Session, error) { 12 | return client.withCtx.GetSession(context.Background()) 13 | } 14 | -------------------------------------------------------------------------------- /settings.go: -------------------------------------------------------------------------------- 1 | //go:generate go run tools/withoutctx.go 2 | package redash 3 | 4 | import ( 5 | "context" 6 | 7 | "github.com/winebarrel/redash-go/v2/internal/util" 8 | ) 9 | 10 | type SettingsOrganization struct { 11 | SettingsOrganizationSettings `json:"settings"` 12 | } 13 | 14 | type SettingsOrganizationSettings struct { 15 | AuthGoogleAppsDomains []string `json:"auth_google_apps_domains"` 16 | AuthJwtAuthAlgorithms []string `json:"auth_jwt_auth_algorithms"` 17 | AuthJwtAuthAudience string `json:"auth_jwt_auth_audience"` 18 | AuthJwtAuthCookieName string `json:"auth_jwt_auth_cookie_name"` 19 | AuthJwtAuthHeaderName string `json:"auth_jwt_auth_header_name"` 20 | AuthJwtAuthIssuer string `json:"auth_jwt_auth_issuer"` 21 | AuthJwtAuthPublicCertsURL string `json:"auth_jwt_auth_public_certs_url"` 22 | AuthJwtLoginEnabled bool `json:"auth_jwt_login_enabled"` 23 | AuthPasswordLoginEnabled bool `json:"auth_password_login_enabled"` 24 | AuthSamlEnabled bool `json:"auth_saml_enabled"` 25 | AuthSamlEntityID string `json:"auth_saml_entity_id"` 26 | AuthSamlMetadataURL string `json:"auth_saml_metadata_url"` 27 | AuthSamlNameidFormat string `json:"auth_saml_nameid_format"` 28 | AuthSamlSsoURL string `json:"auth_saml_sso_url"` 29 | AuthSamlType string `json:"auth_saml_type"` 30 | AuthSamlX509Cert string `json:"auth_saml_x509_cert"` 31 | BeaconConsent bool `json:"beacon_consent"` 32 | DateFormat string `json:"date_format"` 33 | DisablePublicUrls bool `json:"disable_public_urls"` 34 | FeatureShowPermissionsControl bool `json:"feature_show_permissions_control"` 35 | FloatFormat string `json:"float_format"` 36 | HidePlotlyModeBar bool `json:"hide_plotly_mode_bar"` 37 | IntegerFormat string `json:"integer_format"` 38 | MultiByteSearchEnabled bool `json:"multi_byte_search_enabled"` 39 | SendEmailOnFailedScheduledQueries bool `json:"send_email_on_failed_scheduled_queries"` 40 | TimeFormat string `json:"time_format"` 41 | } 42 | 43 | func (client *Client) GetSettingsOrganization(ctx context.Context) (*SettingsOrganization, error) { 44 | res, close, err := client.Get(ctx, "api/settings/organization", nil) 45 | defer close() 46 | 47 | if err != nil { 48 | return nil, err 49 | } 50 | 51 | org := &SettingsOrganization{} 52 | 53 | if err := util.UnmarshalBody(res, &org); err != nil { 54 | return nil, err 55 | } 56 | 57 | return org, nil 58 | } 59 | 60 | type UpdateSettingsOrganizationInput struct { 61 | AuthGoogleAppsDomains []string `json:"auth_google_apps_domains,omitempty"` 62 | AuthJwtAuthAlgorithms []string `json:"auth_jwt_auth_algorithms,omitempty"` 63 | AuthJwtAuthAudience string `json:"auth_jwt_auth_audience,omitempty"` 64 | AuthJwtAuthCookieName string `json:"auth_jwt_auth_cookie_name,omitempty"` 65 | AuthJwtAuthHeaderName string `json:"auth_jwt_auth_header_name,omitempty"` 66 | AuthJwtAuthIssuer string `json:"auth_jwt_auth_issuer,omitempty"` 67 | AuthJwtAuthPublicCertsURL string `json:"auth_jwt_auth_public_certs_url,omitempty"` 68 | AuthJwtLoginEnabled bool `json:"auth_jwt_login_enabled,omitempty"` 69 | AuthPasswordLoginEnabled bool `json:"auth_password_login_enabled,omitempty"` 70 | AuthSamlEnabled bool `json:"auth_saml_enabled,omitempty"` 71 | AuthSamlEntityID string `json:"auth_saml_entity_id,omitempty"` 72 | AuthSamlMetadataURL string `json:"auth_saml_metadata_url,omitempty"` 73 | AuthSamlNameidFormat string `json:"auth_saml_nameid_format,omitempty"` 74 | AuthSamlSsoURL string `json:"auth_saml_sso_url,omitempty"` 75 | AuthSamlType string `json:"auth_saml_type,omitempty"` 76 | AuthSamlX509Cert string `json:"auth_saml_x509_cert,omitempty"` 77 | BeaconConsent bool `json:"beacon_consent,omitempty"` 78 | DateFormat string `json:"date_format,omitempty"` 79 | DisablePublicUrls bool `json:"disable_public_urls,omitempty"` 80 | FeatureShowPermissionsControl bool `json:"feature_show_permissions_control,omitempty"` 81 | FloatFormat string `json:"float_format,omitempty"` 82 | HidePlotlyModeBar bool `json:"hide_plotly_mode_bar,omitempty"` 83 | IntegerFormat string `json:"integer_format,omitempty"` 84 | MultiByteSearchEnabled bool `json:"multi_byte_search_enabled,omitempty"` 85 | SendEmailOnFailedScheduledQueries bool `json:"send_email_on_failed_scheduled_queries,omitempty"` 86 | TimeFormat string `json:"time_format,omitempty"` 87 | } 88 | 89 | func (client *Client) UpdateSettingsOrganization(ctx context.Context, input *UpdateSettingsOrganizationInput) (*SettingsOrganization, error) { 90 | res, close, err := client.Post(ctx, "api/settings/organization", input) 91 | defer close() 92 | 93 | if err != nil { 94 | return nil, err 95 | } 96 | 97 | org := &SettingsOrganization{} 98 | 99 | if err := util.UnmarshalBody(res, &org); err != nil { 100 | return nil, err 101 | } 102 | 103 | return org, nil 104 | } 105 | -------------------------------------------------------------------------------- /settings_test.go: -------------------------------------------------------------------------------- 1 | package redash_test 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net/http" 7 | "testing" 8 | 9 | "github.com/jarcoal/httpmock" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | "github.com/winebarrel/redash-go/v2" 13 | ) 14 | 15 | func Test_GetSettingsOrganization_OK(t *testing.T) { 16 | assert := assert.New(t) 17 | httpmock.Activate() 18 | defer httpmock.DeactivateAndReset() 19 | 20 | httpmock.RegisterResponder(http.MethodGet, "https://redash.example.com/api/settings/organization", func(req *http.Request) (*http.Response, error) { 21 | assert.Equal( 22 | http.Header( 23 | http.Header{ 24 | "Authorization": []string{"Key " + testRedashAPIKey}, 25 | "Content-Type": []string{"application/json"}, 26 | "User-Agent": []string{"redash-go"}, 27 | }, 28 | ), 29 | req.Header, 30 | ) 31 | return httpmock.NewStringResponse(http.StatusOK, ` 32 | { 33 | "settings": { 34 | "auth_google_apps_domains": [], 35 | "auth_jwt_auth_algorithms": [ 36 | "HS256", 37 | "RS256", 38 | "ES256" 39 | ], 40 | "auth_jwt_auth_audience": "", 41 | "auth_jwt_auth_cookie_name": "", 42 | "auth_jwt_auth_header_name": "", 43 | "auth_jwt_auth_issuer": "", 44 | "auth_jwt_auth_public_certs_url": "", 45 | "auth_jwt_login_enabled": false, 46 | "auth_password_login_enabled": true, 47 | "auth_saml_enabled": false, 48 | "auth_saml_entity_id": "", 49 | "auth_saml_metadata_url": "", 50 | "auth_saml_nameid_format": "", 51 | "auth_saml_sso_url": "", 52 | "auth_saml_type": "", 53 | "auth_saml_x509_cert": "", 54 | "beacon_consent": false, 55 | "date_format": "DD/MM/YY", 56 | "disable_public_urls": false, 57 | "feature_show_permissions_control": false, 58 | "float_format": "0,0.00", 59 | "hide_plotly_mode_bar": false, 60 | "integer_format": "0,0", 61 | "multi_byte_search_enabled": false, 62 | "send_email_on_failed_scheduled_queries": false, 63 | "time_format": "HH:mm" 64 | } 65 | } 66 | `), nil 67 | }) 68 | 69 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 70 | res, err := client.GetSettingsOrganization(context.Background()) 71 | assert.NoError(err) 72 | assert.Equal(&redash.SettingsOrganization{ 73 | SettingsOrganizationSettings: redash.SettingsOrganizationSettings{ 74 | AuthGoogleAppsDomains: []string{}, 75 | AuthJwtAuthAlgorithms: []string{"HS256", "RS256", "ES256"}, 76 | AuthJwtAuthAudience: "", 77 | AuthJwtAuthCookieName: "", 78 | AuthJwtAuthHeaderName: "", 79 | AuthJwtAuthIssuer: "", 80 | AuthJwtAuthPublicCertsURL: "", 81 | AuthJwtLoginEnabled: false, 82 | AuthPasswordLoginEnabled: true, 83 | AuthSamlEnabled: false, 84 | AuthSamlEntityID: "", 85 | AuthSamlMetadataURL: "", 86 | AuthSamlNameidFormat: "", 87 | AuthSamlSsoURL: "", 88 | AuthSamlType: "", 89 | AuthSamlX509Cert: "", 90 | BeaconConsent: false, 91 | DateFormat: "DD/MM/YY", 92 | DisablePublicUrls: false, 93 | FeatureShowPermissionsControl: false, 94 | FloatFormat: "0,0.00", 95 | HidePlotlyModeBar: false, 96 | IntegerFormat: "0,0", 97 | MultiByteSearchEnabled: false, 98 | SendEmailOnFailedScheduledQueries: false, 99 | TimeFormat: "HH:mm", 100 | }, 101 | }, res) 102 | } 103 | 104 | func Test_GetSettingsOrganization_Err_5xx(t *testing.T) { 105 | assert := assert.New(t) 106 | httpmock.Activate() 107 | defer httpmock.DeactivateAndReset() 108 | 109 | httpmock.RegisterResponder(http.MethodGet, "https://redash.example.com/api/settings/organization", func(req *http.Request) (*http.Response, error) { 110 | return httpmock.NewStringResponse(http.StatusServiceUnavailable, "error"), nil 111 | }) 112 | 113 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 114 | _, err := client.GetSettingsOrganization(context.Background()) 115 | assert.ErrorContains(err, "GET api/settings/organization failed: HTTP status code not OK: 503 Service Unavailable\nerror") 116 | } 117 | 118 | func Test_GetSettingsOrganization_IOErr(t *testing.T) { 119 | assert := assert.New(t) 120 | httpmock.Activate() 121 | defer httpmock.DeactivateAndReset() 122 | 123 | httpmock.RegisterResponder(http.MethodGet, "https://redash.example.com/api/settings/organization", func(req *http.Request) (*http.Response, error) { 124 | return testIOErrResp, nil 125 | }) 126 | 127 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 128 | _, err := client.GetSettingsOrganization(context.Background()) 129 | assert.ErrorContains(err, "read response body failed: IO error") 130 | } 131 | 132 | func Test_UpdateSettingsOrganization_OK(t *testing.T) { 133 | assert := assert.New(t) 134 | httpmock.Activate() 135 | defer httpmock.DeactivateAndReset() 136 | 137 | httpmock.RegisterResponder(http.MethodPost, "https://redash.example.com/api/settings/organization", func(req *http.Request) (*http.Response, error) { 138 | assert.Equal( 139 | http.Header( 140 | http.Header{ 141 | "Authorization": []string{"Key " + testRedashAPIKey}, 142 | "Content-Type": []string{"application/json"}, 143 | "User-Agent": []string{"redash-go"}, 144 | }, 145 | ), 146 | req.Header, 147 | ) 148 | if req.Body == nil { 149 | assert.FailNow("req.Body is nil") 150 | } 151 | body, _ := io.ReadAll(req.Body) 152 | assert.Equal(`{"date_format":"YYYY/MM/DD"}`, string(body)) 153 | return httpmock.NewStringResponse(http.StatusOK, ` 154 | { 155 | "settings": { 156 | "auth_google_apps_domains": [], 157 | "auth_jwt_auth_algorithms": [ 158 | "HS256", 159 | "RS256", 160 | "ES256" 161 | ], 162 | "auth_jwt_auth_audience": "", 163 | "auth_jwt_auth_cookie_name": "", 164 | "auth_jwt_auth_header_name": "", 165 | "auth_jwt_auth_issuer": "", 166 | "auth_jwt_auth_public_certs_url": "", 167 | "auth_jwt_login_enabled": false, 168 | "auth_password_login_enabled": true, 169 | "auth_saml_enabled": false, 170 | "auth_saml_entity_id": "", 171 | "auth_saml_metadata_url": "", 172 | "auth_saml_nameid_format": "", 173 | "auth_saml_sso_url": "", 174 | "auth_saml_type": "", 175 | "auth_saml_x509_cert": "", 176 | "beacon_consent": false, 177 | "date_format": "YYYY/MM/DD", 178 | "disable_public_urls": false, 179 | "feature_show_permissions_control": false, 180 | "float_format": "0,0.00", 181 | "hide_plotly_mode_bar": false, 182 | "integer_format": "0,0", 183 | "multi_byte_search_enabled": false, 184 | "send_email_on_failed_scheduled_queries": false, 185 | "time_format": "HH:mm" 186 | } 187 | } 188 | `), nil 189 | }) 190 | 191 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 192 | res, err := client.UpdateSettingsOrganization(context.Background(), &redash.UpdateSettingsOrganizationInput{ 193 | DateFormat: "YYYY/MM/DD", 194 | }) 195 | assert.NoError(err) 196 | assert.Equal(&redash.SettingsOrganization{ 197 | SettingsOrganizationSettings: redash.SettingsOrganizationSettings{ 198 | AuthGoogleAppsDomains: []string{}, 199 | AuthJwtAuthAlgorithms: []string{"HS256", "RS256", "ES256"}, 200 | AuthJwtAuthAudience: "", 201 | AuthJwtAuthCookieName: "", 202 | AuthJwtAuthHeaderName: "", 203 | AuthJwtAuthIssuer: "", 204 | AuthJwtAuthPublicCertsURL: "", 205 | AuthJwtLoginEnabled: false, 206 | AuthPasswordLoginEnabled: true, 207 | AuthSamlEnabled: false, 208 | AuthSamlEntityID: "", 209 | AuthSamlMetadataURL: "", 210 | AuthSamlNameidFormat: "", 211 | AuthSamlSsoURL: "", 212 | AuthSamlType: "", 213 | AuthSamlX509Cert: "", 214 | BeaconConsent: false, 215 | DateFormat: "YYYY/MM/DD", 216 | DisablePublicUrls: false, 217 | FeatureShowPermissionsControl: false, 218 | FloatFormat: "0,0.00", 219 | HidePlotlyModeBar: false, 220 | IntegerFormat: "0,0", 221 | MultiByteSearchEnabled: false, 222 | SendEmailOnFailedScheduledQueries: false, 223 | TimeFormat: "HH:mm", 224 | }, 225 | }, res) 226 | } 227 | 228 | func Test_UpdateSettingsOrganization_Err_5xx(t *testing.T) { 229 | assert := assert.New(t) 230 | httpmock.Activate() 231 | defer httpmock.DeactivateAndReset() 232 | 233 | httpmock.RegisterResponder(http.MethodPost, "https://redash.example.com/api/settings/organization", func(req *http.Request) (*http.Response, error) { 234 | return httpmock.NewStringResponse(http.StatusServiceUnavailable, "error"), nil 235 | }) 236 | 237 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 238 | _, err := client.UpdateSettingsOrganization(context.Background(), &redash.UpdateSettingsOrganizationInput{ 239 | DateFormat: "YYYY/MM/DD", 240 | }) 241 | assert.ErrorContains(err, "POST api/settings/organization failed: HTTP status code not OK: 503 Service Unavailable\nerror") 242 | } 243 | 244 | func Test_UpdateSettingsOrganization_IOErr(t *testing.T) { 245 | assert := assert.New(t) 246 | httpmock.Activate() 247 | defer httpmock.DeactivateAndReset() 248 | 249 | httpmock.RegisterResponder(http.MethodPost, "https://redash.example.com/api/settings/organization", func(req *http.Request) (*http.Response, error) { 250 | return testIOErrResp, nil 251 | }) 252 | 253 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 254 | _, err := client.UpdateSettingsOrganization(context.Background(), &redash.UpdateSettingsOrganizationInput{ 255 | DateFormat: "YYYY/MM/DD", 256 | }) 257 | assert.ErrorContains(err, "read response body failed: IO error") 258 | } 259 | 260 | func Test_Settings_Acc(t *testing.T) { 261 | if !testAcc { 262 | t.Skip() 263 | } 264 | 265 | assert := assert.New(t) 266 | require := require.New(t) 267 | client, _ := redash.NewClient(testRedashEndpoint, testRedashAPIKey) 268 | settings, err := client.GetSettingsOrganization(context.Background()) 269 | require.NoError(err) 270 | assert.NotEmpty(settings.DateFormat) 271 | 272 | settings, err = client.UpdateSettingsOrganization(context.Background(), &redash.UpdateSettingsOrganizationInput{ 273 | DateFormat: "YYYY/MM/DD", 274 | }) 275 | require.NoError(err) 276 | assert.Equal("YYYY/MM/DD", settings.DateFormat) 277 | } 278 | -------------------------------------------------------------------------------- /settings_without_ctx.go: -------------------------------------------------------------------------------- 1 | // Code generated from settings.go using tools/withoutctx.go; DO NOT EDIT. 2 | 3 | package redash 4 | 5 | import "context" 6 | 7 | func (client *ClientWithoutContext) GetSettingsOrganization() (*SettingsOrganization, error) { 8 | return client.withCtx.GetSettingsOrganization(context.Background()) 9 | } 10 | 11 | func (client *ClientWithoutContext) UpdateSettingsOrganization(input *UpdateSettingsOrganizationInput) (*SettingsOrganization, error) { 12 | return client.withCtx.UpdateSettingsOrganization(context.Background(), input) 13 | } 14 | -------------------------------------------------------------------------------- /status.go: -------------------------------------------------------------------------------- 1 | //go:generate go run tools/withoutctx.go 2 | package redash 3 | 4 | import ( 5 | "context" 6 | 7 | "github.com/winebarrel/redash-go/v2/internal/util" 8 | ) 9 | 10 | type Status struct { 11 | DashboardsCount int `json:"dashboards_count"` 12 | DatabaseMetrics StatusDatabaseMetrics `json:"database_metrics"` 13 | Manager StatusManager `json:"manager"` 14 | QueriesCount int `json:"queries_count"` 15 | QueryResultsCount int `json:"query_results_count"` 16 | RedisUsedMemory int `json:"redis_used_memory"` 17 | RedisUsedMemoryHuman string `json:"redis_used_memory_human"` 18 | UnusedQueryResultsCount int `json:"unused_query_results_count"` 19 | Version string `json:"version"` 20 | WidgetsCount int `json:"widgets_count"` 21 | Workers []any `json:"workers"` 22 | } 23 | 24 | type StatusDatabaseMetrics struct { 25 | Metrics [][]any `json:"metrics"` 26 | } 27 | 28 | type StatusManager struct { 29 | LastRefreshAt string `json:"last_refresh_at"` 30 | OutdatedQueriesCount string `json:"outdated_queries_count"` 31 | QueryIds string `json:"query_ids"` 32 | Queues StatusManagerQueues `json:"queues"` 33 | } 34 | 35 | type StatusManagerQueues struct { 36 | Celery StatusManagerQueuesCelery `json:"celery"` 37 | Queries StatusManagerQueuesQueries `json:"queries"` 38 | ScheduledQueries StatusManagerQueuesScheduledQueries `json:"scheduled_queries"` 39 | } 40 | 41 | type StatusManagerQueuesCelery struct { 42 | Size int `json:"size"` 43 | } 44 | 45 | type StatusManagerQueuesQueries struct { 46 | Size int `json:"size"` 47 | } 48 | 49 | type StatusManagerQueuesScheduledQueries struct { 50 | Size int `json:"size"` 51 | } 52 | 53 | func (client *Client) GetStatus(ctx context.Context) (*Status, error) { 54 | res, close, err := client.Get(ctx, "status.json", nil) 55 | defer close() 56 | 57 | if err != nil { 58 | return nil, err 59 | } 60 | 61 | status := &Status{} 62 | 63 | if err := util.UnmarshalBody(res, &status); err != nil { 64 | return nil, err 65 | } 66 | 67 | return status, nil 68 | } 69 | -------------------------------------------------------------------------------- /status_test.go: -------------------------------------------------------------------------------- 1 | package redash_test 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/jarcoal/httpmock" 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | "github.com/winebarrel/redash-go/v2" 12 | ) 13 | 14 | func Test_GetStatus_OK(t *testing.T) { 15 | assert := assert.New(t) 16 | httpmock.Activate() 17 | defer httpmock.DeactivateAndReset() 18 | 19 | httpmock.RegisterResponder(http.MethodGet, "https://redash.example.com/status.json", func(req *http.Request) (*http.Response, error) { 20 | assert.Equal( 21 | http.Header( 22 | http.Header{ 23 | "Authorization": []string{"Key " + testRedashAPIKey}, 24 | "Content-Type": []string{"application/json"}, 25 | "User-Agent": []string{"redash-go"}, 26 | }, 27 | ), 28 | req.Header, 29 | ) 30 | return httpmock.NewStringResponse(http.StatusOK, ` 31 | { 32 | "dashboards_count": 2, 33 | "database_metrics": { 34 | "metrics": [ 35 | [ 36 | "Query Results Size", 37 | 49152 38 | ], 39 | [ 40 | "Redash DB Size", 41 | 9371820 42 | ] 43 | ] 44 | }, 45 | "manager": { 46 | "last_refresh_at": "1676217265.221729", 47 | "outdated_queries_count": "0", 48 | "query_ids": "[]", 49 | "queues": { 50 | "celery": { 51 | "size": 0 52 | }, 53 | "queries": { 54 | "size": 0 55 | }, 56 | "scheduled_queries": { 57 | "size": 0 58 | } 59 | } 60 | }, 61 | "queries_count": 5, 62 | "query_results_count": 4, 63 | "redis_used_memory": 2383280, 64 | "redis_used_memory_human": "2.27M", 65 | "unused_query_results_count": 0, 66 | "version": "8.0.0+b32245", 67 | "widgets_count": 0, 68 | "workers": [] 69 | } 70 | `), nil 71 | }) 72 | 73 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 74 | res, err := client.GetStatus(context.Background()) 75 | assert.NoError(err) 76 | assert.Equal(&redash.Status{ 77 | DashboardsCount: 2, 78 | DatabaseMetrics: redash.StatusDatabaseMetrics{ 79 | Metrics: [][]any{ 80 | {"Query Results Size", float64(49152)}, 81 | {"Redash DB Size", 9.37182e+06}, 82 | }, 83 | }, 84 | Manager: redash.StatusManager{ 85 | LastRefreshAt: "1676217265.221729", 86 | OutdatedQueriesCount: "0", 87 | QueryIds: "[]", 88 | Queues: redash.StatusManagerQueues{ 89 | Celery: redash.StatusManagerQueuesCelery{ 90 | Size: 0, 91 | }, 92 | Queries: redash.StatusManagerQueuesQueries{ 93 | Size: 0, 94 | }, 95 | ScheduledQueries: redash.StatusManagerQueuesScheduledQueries{ 96 | Size: 0, 97 | }, 98 | }, 99 | }, 100 | QueriesCount: 5, 101 | QueryResultsCount: 4, 102 | RedisUsedMemory: 2383280, 103 | RedisUsedMemoryHuman: "2.27M", 104 | UnusedQueryResultsCount: 0, 105 | Version: "8.0.0+b32245", 106 | WidgetsCount: 0, 107 | Workers: []any{}, 108 | }, res) 109 | } 110 | 111 | func Test_GetStatus_Err_5xx(t *testing.T) { 112 | assert := assert.New(t) 113 | httpmock.Activate() 114 | defer httpmock.DeactivateAndReset() 115 | 116 | httpmock.RegisterResponder(http.MethodGet, "https://redash.example.com/status.json", func(req *http.Request) (*http.Response, error) { 117 | return httpmock.NewStringResponse(http.StatusServiceUnavailable, "error"), nil 118 | }) 119 | 120 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 121 | _, err := client.GetStatus(context.Background()) 122 | assert.ErrorContains(err, "GET status.json failed: HTTP status code not OK: 503 Service Unavailable\nerror") 123 | } 124 | 125 | func Test_GetStatus_IOErr(t *testing.T) { 126 | assert := assert.New(t) 127 | httpmock.Activate() 128 | defer httpmock.DeactivateAndReset() 129 | 130 | httpmock.RegisterResponder(http.MethodGet, "https://redash.example.com/status.json", func(req *http.Request) (*http.Response, error) { 131 | return testIOErrResp, nil 132 | }) 133 | 134 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 135 | _, err := client.GetStatus(context.Background()) 136 | assert.ErrorContains(err, "read response body failed: IO error") 137 | } 138 | 139 | func Test_GetStatus_Acc(t *testing.T) { 140 | if !testAcc { 141 | t.Skip() 142 | } 143 | 144 | assert := assert.New(t) 145 | require := require.New(t) 146 | client, _ := redash.NewClient(testRedashEndpoint, testRedashAPIKey) 147 | status, err := client.GetStatus(context.Background()) 148 | require.NoError(err) 149 | assert.NotEmpty(status.Version) 150 | } 151 | -------------------------------------------------------------------------------- /status_without_ctx.go: -------------------------------------------------------------------------------- 1 | // Code generated from status.go using tools/withoutctx.go; DO NOT EDIT. 2 | 3 | package redash 4 | 5 | import "context" 6 | 7 | func (client *ClientWithoutContext) GetStatus() (*Status, error) { 8 | return client.withCtx.GetStatus(context.Background()) 9 | } 10 | -------------------------------------------------------------------------------- /tools/withoutctx.go: -------------------------------------------------------------------------------- 1 | //go:build tools 2 | 3 | package main 4 | 5 | import ( 6 | "bytes" 7 | "fmt" 8 | "go/ast" 9 | "go/format" 10 | "go/parser" 11 | "go/token" 12 | "log" 13 | "os" 14 | "strings" 15 | 16 | "golang.org/x/tools/imports" 17 | ) 18 | 19 | var fset = token.NewFileSet() 20 | 21 | func main() { 22 | filename := os.Getenv("GOFILE") 23 | af, err := parser.ParseFile(fset, filename, nil, 0) 24 | 25 | if err != nil { 26 | log.Fatal(err) 27 | } 28 | 29 | af.Doc = nil 30 | af.Imports = nil 31 | af.Comments = nil 32 | 33 | var newDecl []ast.Decl 34 | 35 | for _, d := range af.Decls { 36 | fd, ok := d.(*ast.FuncDecl) 37 | 38 | if !ok { 39 | continue 40 | } 41 | 42 | if fd.Recv == nil || !fd.Name.IsExported() { 43 | continue 44 | } 45 | 46 | typ := fd.Type 47 | 48 | if len(typ.Params.List) < 1 { 49 | continue 50 | } 51 | 52 | arg0 := typ.Params.List[0] 53 | arg0Name := arg0.Names[0].Name 54 | arg0Type := arg0.Type.(*ast.SelectorExpr) 55 | 56 | if arg0Name != "ctx" || arg0Type.Sel.Name != "Context" { 57 | continue 58 | } 59 | 60 | recvType := fd.Recv.List[0].Type.(*ast.StarExpr).X.(*ast.Ident) 61 | recvType.Name = "ClientWithoutContext" 62 | 63 | origParams := []ast.Expr{ 64 | &ast.CallExpr{ 65 | Fun: &ast.SelectorExpr{ 66 | X: ast.NewIdent("context"), 67 | Sel: ast.NewIdent("Background"), 68 | }, 69 | }, 70 | } 71 | 72 | paramsList1 := typ.Params.List[1:len(typ.Params.List)] 73 | 74 | for _, p := range paramsList1 { 75 | origParams = append(origParams, &ast.BasicLit{ 76 | Kind: token.IDENT, 77 | Value: p.Names[0].Name, 78 | }) 79 | } 80 | 81 | fd.Body = &ast.BlockStmt{ 82 | List: []ast.Stmt{ 83 | &ast.ReturnStmt{ 84 | Results: []ast.Expr{ 85 | &ast.CallExpr{ 86 | Fun: &ast.SelectorExpr{ 87 | X: ast.NewIdent("client.withCtx"), 88 | Sel: ast.NewIdent(fd.Name.Name), 89 | }, 90 | Args: origParams, 91 | }, 92 | }, 93 | }, 94 | }, 95 | } 96 | 97 | typ.Params.List = paramsList1 98 | newDecl = append(newDecl, fd) 99 | } 100 | 101 | af.Decls = newDecl 102 | var out bytes.Buffer 103 | 104 | if err := format.Node(&out, fset, af); err != nil { 105 | log.Fatal(err) 106 | } 107 | 108 | src, err := imports.Process(filename, out.Bytes(), nil) 109 | 110 | if err != nil { 111 | log.Fatal(err) 112 | } 113 | 114 | src, err = format.Source(src) 115 | 116 | if err != nil { 117 | log.Fatal(err) 118 | } 119 | 120 | dst, err := os.Create(strings.ReplaceAll(filename, ".go", "_without_ctx.go")) 121 | 122 | if err != nil { 123 | log.Fatal(err) 124 | } 125 | 126 | defer dst.Close() 127 | fmt.Fprintf(dst, "// Code generated from %s using tools/withoutctx.go; DO NOT EDIT.\n\n", filename) 128 | fmt.Fprintln(dst, string(bytes.TrimSpace(src))) 129 | } 130 | -------------------------------------------------------------------------------- /user.go: -------------------------------------------------------------------------------- 1 | //go:generate go run tools/withoutctx.go 2 | package redash 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | "time" 8 | 9 | "github.com/winebarrel/redash-go/v2/internal/util" 10 | ) 11 | 12 | type UserPage struct { 13 | Count int `json:"count"` 14 | Page int `json:"page"` 15 | PageSize int `json:"page_size"` 16 | Results []User `json:"results"` 17 | } 18 | 19 | type User struct { 20 | ActiveAt time.Time `json:"active_at"` 21 | APIKey string `json:"api_key"` 22 | AuthType string `json:"auth_type"` 23 | CreatedAt time.Time `json:"created_at"` 24 | DisabledAt time.Time `json:"disabled_at"` 25 | Email string `json:"email"` 26 | Groups []any `json:"groups"` 27 | ID int `json:"id"` 28 | IsDisabled bool `json:"is_disabled"` 29 | IsEmailVerified bool `json:"is_email_verified"` 30 | IsInvitationPending bool `json:"is_invitation_pending"` 31 | Name string `json:"name"` 32 | ProfileImageURL string `json:"profile_image_url"` 33 | UpdatedAt time.Time `json:"updated_at"` 34 | } 35 | 36 | type ListUsersInput struct { 37 | Page int `url:"page,omitempty"` 38 | PageSize int `url:"page_size,omitempty"` 39 | } 40 | 41 | func (client *Client) ListUsers(ctx context.Context, input *ListUsersInput) (*UserPage, error) { 42 | res, close, err := client.Get(ctx, "api/users", input) 43 | defer close() 44 | 45 | if err != nil { 46 | return nil, err 47 | } 48 | 49 | page := &UserPage{} 50 | 51 | if err := util.UnmarshalBody(res, &page); err != nil { 52 | return nil, err 53 | } 54 | 55 | return page, nil 56 | } 57 | 58 | func (client *Client) GetUser(ctx context.Context, id int) (*User, error) { 59 | res, close, err := client.Get(ctx, fmt.Sprintf("api/users/%d", id), nil) 60 | defer close() 61 | 62 | if err != nil { 63 | return nil, err 64 | } 65 | 66 | user := &User{} 67 | 68 | if err := util.UnmarshalBody(res, &user); err != nil { 69 | return nil, err 70 | } 71 | 72 | return user, nil 73 | } 74 | 75 | type CreateUsersInput struct { 76 | AuthType string `json:"auth_type"` 77 | Email string `json:"email"` 78 | Name string `json:"name"` 79 | } 80 | 81 | func (client *Client) CreateUser(ctx context.Context, input *CreateUsersInput) (*User, error) { 82 | res, close, err := client.Post(ctx, "api/users", input) 83 | defer close() 84 | 85 | if err != nil { 86 | return nil, err 87 | } 88 | 89 | user := &User{} 90 | 91 | if err := util.UnmarshalBody(res, &user); err != nil { 92 | return nil, err 93 | } 94 | 95 | return user, nil 96 | } 97 | 98 | type UpdateUserInput struct { 99 | Email string `json:"email,omitempty"` 100 | Name string `json:"name,omitempty"` 101 | } 102 | 103 | func (client *Client) UpdateUser(ctx context.Context, id int, input *UpdateUserInput) (*User, error) { 104 | res, close, err := client.Post(ctx, fmt.Sprintf("api/users/%d", id), input) 105 | defer close() 106 | 107 | if err != nil { 108 | return nil, err 109 | } 110 | 111 | user := &User{} 112 | 113 | if err := util.UnmarshalBody(res, &user); err != nil { 114 | return nil, err 115 | } 116 | 117 | return user, nil 118 | } 119 | 120 | func (client *Client) DeleteUser(ctx context.Context, id int) error { 121 | _, close, err := client.Delete(ctx, fmt.Sprintf("api/users/%d", id)) 122 | defer close() 123 | 124 | if err != nil { 125 | return err 126 | } 127 | 128 | return nil 129 | } 130 | 131 | func (client *Client) DisableUser(ctx context.Context, id int) (*User, error) { 132 | res, close, err := client.Post(ctx, fmt.Sprintf("api/users/%d/disable", id), nil) 133 | defer close() 134 | 135 | if err != nil { 136 | return nil, err 137 | } 138 | 139 | user := &User{} 140 | 141 | if err := util.UnmarshalBody(res, &user); err != nil { 142 | return nil, err 143 | } 144 | 145 | return user, nil 146 | } 147 | 148 | func (client *Client) EnableUser(ctx context.Context, id int) (*User, error) { 149 | res, close, err := client.Delete(ctx, fmt.Sprintf("api/users/%d/disable", id)) 150 | defer close() 151 | 152 | if err != nil { 153 | return nil, err 154 | } 155 | 156 | user := &User{} 157 | 158 | if err := util.UnmarshalBody(res, &user); err != nil { 159 | return nil, err 160 | } 161 | 162 | return user, nil 163 | } 164 | -------------------------------------------------------------------------------- /user_without_ctx.go: -------------------------------------------------------------------------------- 1 | // Code generated from user.go using tools/withoutctx.go; DO NOT EDIT. 2 | 3 | package redash 4 | 5 | import "context" 6 | 7 | func (client *ClientWithoutContext) ListUsers(input *ListUsersInput) (*UserPage, error) { 8 | return client.withCtx.ListUsers(context.Background(), input) 9 | } 10 | 11 | func (client *ClientWithoutContext) GetUser(id int) (*User, error) { 12 | return client.withCtx.GetUser(context.Background(), id) 13 | } 14 | 15 | func (client *ClientWithoutContext) CreateUser(input *CreateUsersInput) (*User, error) { 16 | return client.withCtx.CreateUser(context.Background(), input) 17 | } 18 | 19 | func (client *ClientWithoutContext) UpdateUser(id int, input *UpdateUserInput) (*User, error) { 20 | return client.withCtx.UpdateUser(context.Background(), id, input) 21 | } 22 | 23 | func (client *ClientWithoutContext) DeleteUser(id int) error { 24 | return client.withCtx.DeleteUser(context.Background(), id) 25 | } 26 | 27 | func (client *ClientWithoutContext) DisableUser(id int) (*User, error) { 28 | return client.withCtx.DisableUser(context.Background(), id) 29 | } 30 | 31 | func (client *ClientWithoutContext) EnableUser(id int) (*User, error) { 32 | return client.withCtx.EnableUser(context.Background(), id) 33 | } 34 | -------------------------------------------------------------------------------- /visualization.go: -------------------------------------------------------------------------------- 1 | //go:generate go run tools/withoutctx.go 2 | package redash 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | "time" 8 | 9 | "github.com/winebarrel/redash-go/v2/internal/util" 10 | ) 11 | 12 | type Visualization struct { 13 | CreatedAt time.Time `json:"created_at"` 14 | Description string `json:"description"` 15 | ID int `json:"id"` 16 | Name string `json:"name"` 17 | Options any `json:"options"` 18 | Query Query `json:"query"` 19 | Type string `json:"type"` 20 | UpdatedAt time.Time `json:"updated_at"` 21 | } 22 | 23 | type UpdateVisualizationInput struct { 24 | Description string `json:"description,omitempty"` 25 | Name string `json:"name,omitempty"` 26 | Options any `json:"options,omitempty"` 27 | Type string `json:"type,omitempty"` 28 | } 29 | 30 | func (client *Client) UpdateVisualization(ctx context.Context, id int, input *UpdateVisualizationInput) (*Visualization, error) { 31 | res, close, err := client.Post(ctx, fmt.Sprintf("api/visualizations/%d", id), input) 32 | defer close() 33 | 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | viz := &Visualization{} 39 | 40 | if err := util.UnmarshalBody(res, &viz); err != nil { 41 | return nil, err 42 | } 43 | 44 | return viz, nil 45 | } 46 | -------------------------------------------------------------------------------- /visualization_test.go: -------------------------------------------------------------------------------- 1 | package redash_test 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net/http" 7 | "testing" 8 | 9 | "github.com/araddon/dateparse" 10 | "github.com/jarcoal/httpmock" 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | "github.com/winebarrel/redash-go/v2" 14 | ) 15 | 16 | func Test_UpdateVisualization_OK(t *testing.T) { 17 | assert := assert.New(t) 18 | httpmock.Activate() 19 | defer httpmock.DeactivateAndReset() 20 | 21 | httpmock.RegisterResponder(http.MethodPost, "https://redash.example.com/api/visualizations/1", func(req *http.Request) (*http.Response, error) { 22 | assert.Equal( 23 | http.Header( 24 | http.Header{ 25 | "Authorization": []string{"Key " + testRedashAPIKey}, 26 | "Content-Type": []string{"application/json"}, 27 | "User-Agent": []string{"redash-go"}, 28 | }, 29 | ), 30 | req.Header, 31 | ) 32 | if req.Body == nil { 33 | assert.FailNow("req.Body is nil") 34 | } 35 | body, _ := io.ReadAll(req.Body) 36 | assert.Equal(`{"description":"description","name":"name","type":"TABLE"}`, string(body)) 37 | return httpmock.NewStringResponse(http.StatusOK, ` 38 | { 39 | "created_at": "2023-02-10T01:23:45.000Z", 40 | "description": "description", 41 | "id": 1, 42 | "name": "Table", 43 | "options": {}, 44 | "type": "TABLE", 45 | "updated_at": "2023-02-10T01:23:45.000Z" 46 | } 47 | `), nil 48 | }) 49 | 50 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 51 | res, err := client.UpdateVisualization(context.Background(), 1, &redash.UpdateVisualizationInput{ 52 | Description: "description", 53 | Name: "name", 54 | Type: "TABLE", 55 | }) 56 | assert.NoError(err) 57 | assert.Equal(&redash.Visualization{ 58 | CreatedAt: dateparse.MustParse("2023-02-10T01:23:45.000Z"), 59 | Description: "description", 60 | ID: 1, 61 | Name: "Table", 62 | Options: map[string]any{}, 63 | Query: redash.Query{}, 64 | Type: "TABLE", 65 | UpdatedAt: dateparse.MustParse("2023-02-10T01:23:45.000Z"), 66 | }, res) 67 | } 68 | 69 | func Test_UpdateVisualization_IOErr(t *testing.T) { 70 | assert := assert.New(t) 71 | httpmock.Activate() 72 | defer httpmock.DeactivateAndReset() 73 | 74 | httpmock.RegisterResponder(http.MethodPost, "https://redash.example.com/api/visualizations/1", func(req *http.Request) (*http.Response, error) { 75 | return testIOErrResp, nil 76 | }) 77 | 78 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 79 | _, err := client.UpdateVisualization(context.Background(), 1, &redash.UpdateVisualizationInput{ 80 | Description: "description", 81 | Name: "name", 82 | Type: "TABLE", 83 | }) 84 | assert.ErrorContains(err, "read response body failed: IO error") 85 | } 86 | 87 | func Test_UpdateVisualization_Err_5xx(t *testing.T) { 88 | assert := assert.New(t) 89 | httpmock.Activate() 90 | defer httpmock.DeactivateAndReset() 91 | 92 | httpmock.RegisterResponder(http.MethodPost, "https://redash.example.com/api/visualizations/1", func(req *http.Request) (*http.Response, error) { 93 | return httpmock.NewStringResponse(http.StatusServiceUnavailable, "error"), nil 94 | }) 95 | 96 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 97 | _, err := client.UpdateVisualization(context.Background(), 1, &redash.UpdateVisualizationInput{ 98 | Description: "description", 99 | Name: "name", 100 | Type: "TABLE", 101 | }) 102 | assert.ErrorContains(err, "POST api/visualizations/1 failed: HTTP status code not OK: 503 Service Unavailable\nerror") 103 | } 104 | 105 | func Test_Visualization_Acc(t *testing.T) { 106 | if !testAcc { 107 | t.Skip() 108 | } 109 | 110 | assert := assert.New(t) 111 | require := require.New(t) 112 | client, _ := redash.NewClient(testRedashEndpoint, testRedashAPIKey) 113 | ds, err := client.CreateDataSource(context.Background(), &redash.CreateDataSourceInput{ 114 | Name: "test-postgres-1", 115 | Type: "pg", 116 | Options: map[string]any{ 117 | "dbname": "postgres", 118 | "host": "postgres", 119 | "port": 5432, 120 | "user": "postgres", 121 | }, 122 | }) 123 | require.NoError(err) 124 | 125 | defer func() { 126 | client.DeleteDataSource(context.Background(), ds.ID) //nolint:errcheck 127 | }() 128 | 129 | query, _ := client.CreateQuery(context.Background(), &redash.CreateQueryInput{ 130 | DataSourceID: ds.ID, 131 | Name: "test-query-1", 132 | Query: "select 1", 133 | }) 134 | 135 | defer func() { 136 | client.ArchiveQuery(context.Background(), query.ID) //nolint:errcheck 137 | }() 138 | 139 | if len(query.Visualizations) < 1 { 140 | assert.FailNow("len(query.Visualizations) < 1") 141 | } 142 | 143 | vizId := query.Visualizations[0].ID 144 | viz, err := client.UpdateVisualization(context.Background(), vizId, &redash.UpdateVisualizationInput{ 145 | Name: "test-viz-1", 146 | Description: "test-viz-1-desc", 147 | }) 148 | require.NoError(err) 149 | assert.Equal("test-viz-1", viz.Name) 150 | assert.Equal("test-viz-1-desc", viz.Description) 151 | } 152 | -------------------------------------------------------------------------------- /visualization_without_ctx.go: -------------------------------------------------------------------------------- 1 | // Code generated from visualization.go using tools/withoutctx.go; DO NOT EDIT. 2 | 3 | package redash 4 | 5 | import "context" 6 | 7 | func (client *ClientWithoutContext) UpdateVisualization(id int, input *UpdateVisualizationInput) (*Visualization, error) { 8 | return client.withCtx.UpdateVisualization(context.Background(), id, input) 9 | } 10 | -------------------------------------------------------------------------------- /widget.go: -------------------------------------------------------------------------------- 1 | //go:generate go run tools/withoutctx.go 2 | package redash 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | "time" 8 | 9 | "github.com/winebarrel/redash-go/v2/internal/util" 10 | ) 11 | 12 | type Widget struct { 13 | CreatedAt time.Time `json:"created_at"` 14 | DashboardID int `json:"dashboard_id"` 15 | ID int `json:"id"` 16 | Options map[string]any `json:"options"` 17 | Text string `json:"text"` 18 | UpdatedAt time.Time `json:"updated_at"` 19 | Visualization *Visualization `json:"visualization"` 20 | Width int `json:"width"` 21 | } 22 | 23 | type CreateWidgetInput struct { 24 | DashboardID int `json:"dashboard_id"` 25 | Options map[string]any `json:"options"` 26 | Text string `json:"text,omitempty"` 27 | VisualizationID int `json:"visualization_id"` 28 | Width int `json:"width"` 29 | } 30 | 31 | func (client *Client) CreateWidget(ctx context.Context, input *CreateWidgetInput) (*Widget, error) { 32 | // workaround 33 | if input.Width < 1 { 34 | input.Width = 1 35 | } 36 | 37 | res, close, err := client.Post(ctx, "api/widgets", input) 38 | defer close() 39 | 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | widget := &Widget{} 45 | 46 | if err := util.UnmarshalBody(res, &widget); err != nil { 47 | return nil, err 48 | } 49 | 50 | return widget, nil 51 | } 52 | 53 | func (client *Client) DeleteWidget(ctx context.Context, id int) error { 54 | _, close, err := client.Delete(ctx, fmt.Sprintf("api/widgets/%d", id)) 55 | defer close() 56 | 57 | if err != nil { 58 | return err 59 | } 60 | 61 | return nil 62 | } 63 | -------------------------------------------------------------------------------- /widget_test.go: -------------------------------------------------------------------------------- 1 | package redash_test 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net/http" 7 | "testing" 8 | 9 | "github.com/araddon/dateparse" 10 | "github.com/jarcoal/httpmock" 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | "github.com/winebarrel/redash-go/v2" 14 | ) 15 | 16 | func Test_CreateWidget_OK(t *testing.T) { 17 | assert := assert.New(t) 18 | httpmock.Activate() 19 | defer httpmock.DeactivateAndReset() 20 | 21 | httpmock.RegisterResponder(http.MethodPost, "https://redash.example.com/api/widgets", func(req *http.Request) (*http.Response, error) { 22 | assert.Equal( 23 | http.Header( 24 | http.Header{ 25 | "Authorization": []string{"Key " + testRedashAPIKey}, 26 | "Content-Type": []string{"application/json"}, 27 | "User-Agent": []string{"redash-go"}, 28 | }, 29 | ), 30 | req.Header, 31 | ) 32 | if req.Body == nil { 33 | assert.FailNow("req.Body is nil") 34 | } 35 | body, _ := io.ReadAll(req.Body) 36 | assert.Equal(`{"dashboard_id":1,"options":{"isHidden":false},"text":"text","visualization_id":1,"width":1}`, string(body)) 37 | return httpmock.NewStringResponse(http.StatusOK, ` 38 | { 39 | "created_at": "2023-02-10T01:23:45.000Z", 40 | "dashboard_id": 1, 41 | "id": 1, 42 | "options": { 43 | "isHidden": false 44 | }, 45 | "text": "text", 46 | "updated_at": "2023-02-10T01:23:45.000Z", 47 | "visualization": {}, 48 | "width": 1 49 | } 50 | `), nil 51 | }) 52 | 53 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 54 | res, err := client.CreateWidget(context.Background(), &redash.CreateWidgetInput{ 55 | DashboardID: 1, 56 | Options: map[string]any{ 57 | "isHidden": false, 58 | }, 59 | Text: "text", 60 | VisualizationID: 1, 61 | Width: 1, 62 | }) 63 | assert.NoError(err) 64 | assert.Equal(&redash.Widget{ 65 | CreatedAt: dateparse.MustParse("2023-02-10T01:23:45.000Z"), 66 | DashboardID: 1, 67 | ID: 1, 68 | Options: map[string]any{ 69 | "isHidden": false, 70 | }, 71 | Text: "text", 72 | UpdatedAt: dateparse.MustParse("2023-02-10T01:23:45.000Z"), 73 | Visualization: &redash.Visualization{}, 74 | Width: 1, 75 | }, res) 76 | } 77 | 78 | func Test_CreateWidget_Err_5xx(t *testing.T) { 79 | assert := assert.New(t) 80 | httpmock.Activate() 81 | defer httpmock.DeactivateAndReset() 82 | 83 | httpmock.RegisterResponder(http.MethodPost, "https://redash.example.com/api/widgets", func(req *http.Request) (*http.Response, error) { 84 | return httpmock.NewStringResponse(http.StatusServiceUnavailable, "error"), nil 85 | }) 86 | 87 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 88 | _, err := client.CreateWidget(context.Background(), &redash.CreateWidgetInput{ 89 | DashboardID: 1, 90 | Options: map[string]any{ 91 | "isHidden": false, 92 | }, 93 | Text: "text", 94 | VisualizationID: 1, 95 | Width: 1, 96 | }) 97 | assert.ErrorContains(err, "POST api/widgets failed: HTTP status code not OK: 503 Service Unavailable\nerror") 98 | } 99 | 100 | func Test_CreateWidget_IOErr(t *testing.T) { 101 | assert := assert.New(t) 102 | httpmock.Activate() 103 | defer httpmock.DeactivateAndReset() 104 | 105 | httpmock.RegisterResponder(http.MethodPost, "https://redash.example.com/api/widgets", func(req *http.Request) (*http.Response, error) { 106 | return testIOErrResp, nil 107 | }) 108 | 109 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 110 | _, err := client.CreateWidget(context.Background(), &redash.CreateWidgetInput{ 111 | DashboardID: 1, 112 | Options: map[string]any{ 113 | "isHidden": false, 114 | }, 115 | Text: "text", 116 | VisualizationID: 1, 117 | Width: 1, 118 | }) 119 | assert.ErrorContains(err, "read response body failed: IO error") 120 | } 121 | 122 | func Test_CreateWidget_Width0(t *testing.T) { 123 | assert := assert.New(t) 124 | httpmock.Activate() 125 | defer httpmock.DeactivateAndReset() 126 | 127 | httpmock.RegisterResponder(http.MethodPost, "https://redash.example.com/api/widgets", func(req *http.Request) (*http.Response, error) { 128 | assert.Equal( 129 | http.Header( 130 | http.Header{ 131 | "Authorization": []string{"Key " + testRedashAPIKey}, 132 | "Content-Type": []string{"application/json"}, 133 | "User-Agent": []string{"redash-go"}, 134 | }, 135 | ), 136 | req.Header, 137 | ) 138 | if req.Body == nil { 139 | assert.FailNow("req.Body is nil") 140 | } 141 | body, _ := io.ReadAll(req.Body) 142 | assert.Equal(`{"dashboard_id":1,"options":{"isHidden":false},"text":"text","visualization_id":0,"width":1}`, string(body)) 143 | return httpmock.NewStringResponse(http.StatusOK, ` 144 | { 145 | "created_at": "2023-02-10T01:23:45.000Z", 146 | "dashboard_id": 1, 147 | "id": 1, 148 | "options": { 149 | "isHidden": false 150 | }, 151 | "text": "text", 152 | "updated_at": "2023-02-10T01:23:45.000Z", 153 | "width": 0 154 | } 155 | `), nil 156 | }) 157 | 158 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 159 | res, err := client.CreateWidget(context.Background(), &redash.CreateWidgetInput{ 160 | DashboardID: 1, 161 | Options: map[string]any{ 162 | "isHidden": false, 163 | }, 164 | Text: "text", 165 | VisualizationID: 0, 166 | Width: 0, 167 | }) 168 | assert.NoError(err) 169 | assert.Equal(&redash.Widget{ 170 | CreatedAt: dateparse.MustParse("2023-02-10T01:23:45.000Z"), 171 | DashboardID: 1, 172 | ID: 1, 173 | Options: map[string]any{ 174 | "isHidden": false, 175 | }, 176 | Text: "text", 177 | UpdatedAt: dateparse.MustParse("2023-02-10T01:23:45.000Z"), 178 | Width: 0, 179 | }, res) 180 | } 181 | 182 | func Test_DeleteWidget_OK(t *testing.T) { 183 | assert := assert.New(t) 184 | httpmock.Activate() 185 | defer httpmock.DeactivateAndReset() 186 | 187 | httpmock.RegisterResponder(http.MethodDelete, "https://redash.example.com/api/widgets/1", func(req *http.Request) (*http.Response, error) { 188 | assert.Equal( 189 | http.Header( 190 | http.Header{ 191 | "Authorization": []string{"Key " + testRedashAPIKey}, 192 | "Content-Type": []string{"application/json"}, 193 | "User-Agent": []string{"redash-go"}, 194 | }, 195 | ), 196 | req.Header, 197 | ) 198 | return httpmock.NewStringResponse(http.StatusOK, ``), nil 199 | }) 200 | 201 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 202 | err := client.DeleteWidget(context.Background(), 1) 203 | assert.NoError(err) 204 | } 205 | 206 | func Test_DeleteWidget_Err_5xx(t *testing.T) { 207 | assert := assert.New(t) 208 | httpmock.Activate() 209 | defer httpmock.DeactivateAndReset() 210 | 211 | httpmock.RegisterResponder(http.MethodDelete, "https://redash.example.com/api/widgets/1", func(req *http.Request) (*http.Response, error) { 212 | return httpmock.NewStringResponse(http.StatusServiceUnavailable, "error"), nil 213 | }) 214 | 215 | client, _ := redash.NewClient("https://redash.example.com", testRedashAPIKey) 216 | err := client.DeleteWidget(context.Background(), 1) 217 | assert.ErrorContains(err, "DELETE api/widgets/1 failed: HTTP status code not OK: 503 Service Unavailable\nerror") 218 | } 219 | 220 | func Test_Widget_Acc(t *testing.T) { 221 | if !testAcc { 222 | t.Skip() 223 | } 224 | 225 | assert := assert.New(t) 226 | require := require.New(t) 227 | client, _ := redash.NewClient(testRedashEndpoint, testRedashAPIKey) 228 | dashboard, err := client.CreateDashboard(context.Background(), &redash.CreateDashboardInput{ 229 | Name: "test-dashboard-1", 230 | }) 231 | require.NoError(err) 232 | 233 | defer func() { 234 | // NOTE: for v8 235 | // client.ArchiveDashboard(context.Background(), dashboard.Slug) //nolint:errcheck 236 | client.ArchiveDashboard(context.Background(), dashboard.ID) //nolint:errcheck 237 | }() 238 | 239 | widget, err := client.CreateWidget(context.Background(), &redash.CreateWidgetInput{ 240 | DashboardID: dashboard.ID, 241 | Text: "test-widget-1", 242 | Width: 1, 243 | }) 244 | require.NoError(err) 245 | assert.Equal("test-widget-1", widget.Text) 246 | 247 | err = client.DeleteWidget(context.Background(), widget.ID) 248 | require.NoError(err) 249 | } 250 | -------------------------------------------------------------------------------- /widget_without_ctx.go: -------------------------------------------------------------------------------- 1 | // Code generated from widget.go using tools/withoutctx.go; DO NOT EDIT. 2 | 3 | package redash 4 | 5 | import "context" 6 | 7 | func (client *ClientWithoutContext) CreateWidget(input *CreateWidgetInput) (*Widget, error) { 8 | return client.withCtx.CreateWidget(context.Background(), input) 9 | } 10 | 11 | func (client *ClientWithoutContext) DeleteWidget(id int) error { 12 | return client.withCtx.DeleteWidget(context.Background(), id) 13 | } 14 | --------------------------------------------------------------------------------