├── .github ├── renovate.json └── workflows │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── api └── types.go ├── callbacks.go ├── client.go ├── client_test.go ├── errors.go ├── get_documents.go ├── get_documents_test.go ├── go.mod ├── go.sum ├── internal └── requests │ ├── requests.go │ └── requests_test.go ├── mutation.go ├── mutation_test.go ├── package_test.go ├── query.go ├── query_test.go └── utils.go /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["github>sanity-io/renovate-config"] 4 | } 5 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | 6 | jobs: 7 | tests: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Check out this repo 11 | uses: actions/checkout@v2 12 | - name: Set up Go 13 | uses: actions/setup-go@v2 14 | with: {go-version: '^1.13'} 15 | - name: Download dependencies 16 | run: go mod download 17 | - name: Build 18 | run: go build ./... 19 | - name: Test 20 | run: go test ./... 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Sanity 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sanity client in Go 2 | 3 | > **Under development!** For developers *with an adventurous spirit only*. 4 | 5 | This is a client for [Sanity](https://www.sanity.io) written in Go. 6 | 7 | ## Using 8 | 9 | See the [API reference](https://godoc.org/github.com/sanity-io/client-go) for the full documentation. 10 | 11 | ```go 12 | package main 13 | 14 | import ( 15 | "context" 16 | "log" 17 | 18 | sanity "github.com/sanity-io/client-go" 19 | ) 20 | 21 | func main() { 22 | client, err := sanity.VersionV20210325.NewClient("zx3vzmn!", sanity.DefaultDataset, 23 | sanity.WithCallbacks(sanity.Callbacks{ 24 | OnQueryResult: func(result *sanity.QueryResult) { 25 | log.Printf("Sanity queried in %d ms!", result.Time.Milliseconds()) 26 | }, 27 | }), 28 | sanity.WithToken("mytoken")) 29 | if err != nil { 30 | log.Fatal(err) 31 | } 32 | 33 | var project struct { 34 | ID string `json:"_id"` 35 | Title string 36 | } 37 | result, err := client. 38 | Query("*[_type == 'project' && _id == $id][0]"). 39 | Param("id", "123"). 40 | Do(context.Background()) 41 | if err != nil { 42 | log.Fatal(err) 43 | } 44 | 45 | if err := result.Unmarshal(&project); err != nil { 46 | log.Fatal(err) 47 | } 48 | 49 | log.Printf("Project: %+v", project) 50 | } 51 | ``` 52 | 53 | ## Installation 54 | 55 | ``` 56 | go get github.com/sanity-io/client-go 57 | ``` 58 | 59 | ## Requirements 60 | 61 | Go 1.13 or later. 62 | 63 | # License 64 | 65 | See [`LICENSE`](https://github.com/sanity-io/client-go/blob/master/LICENSE) file. 66 | -------------------------------------------------------------------------------- /api/types.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | ) 6 | 7 | type MutateRequest struct { 8 | Mutations []*MutationItem `json:"mutations"` 9 | } 10 | 11 | type MutateResponse struct { 12 | TransactionID string `json:"transactionId"` 13 | Results []*MutateResultItem `json:"results"` 14 | } 15 | 16 | type MutationItem struct { 17 | Create *json.RawMessage `json:"create,omitempty"` 18 | CreateIfNotExists *json.RawMessage `json:"createIfNotExists,omitempty"` 19 | CreateOrReplace *json.RawMessage `json:"createOrReplace,omitempty"` 20 | Delete *Delete `json:"delete,omitempty"` 21 | Patch *Patch `json:"patch,omitempty"` 22 | } 23 | 24 | type Delete struct { 25 | ID string `json:"id"` 26 | } 27 | 28 | type Patch struct { 29 | ID string `json:"id"` 30 | IfRevisionID string `json:"ifRevisionID,omitempty"` 31 | Query string `json:"query,omitempty"` 32 | Set map[string]*json.RawMessage `json:"set,omitempty"` 33 | SetIfMissing map[string]*json.RawMessage `json:"setIfMissing,omitempty"` 34 | DiffMatchPatch map[string]string `json:"diffMatchPatch,omitempty"` 35 | Unset []string `json:"unset,omitempty"` 36 | Insert *Insert `json:"insert,omitempty"` 37 | Inc map[string]float64 `json:"inc,omitempty"` 38 | Dec map[string]float64 `json:"dec,omitempty"` 39 | } 40 | 41 | type Insert struct { 42 | Before string `json:"before,omitempty"` 43 | After string `json:"after,omitempty"` 44 | Replace string `json:"replace,omitempty"` 45 | Items []*json.RawMessage `json:"items"` 46 | } 47 | 48 | type MutateResultItem struct { 49 | Document *json.RawMessage `json:"document"` 50 | } 51 | 52 | // Unmarshal unmarshals the document into the passed-in struct. 53 | func (i *MutateResultItem) Unmarshal(dest interface{}) error { 54 | return json.Unmarshal(*i.Document, dest) 55 | } 56 | 57 | type MutationVisibility string 58 | 59 | const ( 60 | MutationVisibilitySync MutationVisibility = "sync" 61 | MutationVisibilityAsync MutationVisibility = "async" 62 | MutationVisibilityDeferred MutationVisibility = "deferred" 63 | ) 64 | 65 | type QueryRequest struct { 66 | Query string `json:"query"` 67 | Params map[string]*json.RawMessage `json:"params"` 68 | } 69 | 70 | // QueryResponse holds the result of a query API call. 71 | type QueryResponse struct { 72 | // Ms is the time taken, in milliseconds. 73 | Ms float64 `json:"ms"` 74 | 75 | // Query is the GROQ query. 76 | Query string `json:"query"` 77 | 78 | // Result is the raw JSON of the query result. 79 | Result *json.RawMessage `json:"result"` 80 | } 81 | 82 | // GetDocumentsResponse holds result of GET documents API call. 83 | type GetDocumentsResponse struct { 84 | // Documents is slice of documents 85 | Documents []Document `json:"documents"` 86 | } 87 | 88 | // Document is a map of document attributes 89 | type Document map[string]interface{} 90 | -------------------------------------------------------------------------------- /callbacks.go: -------------------------------------------------------------------------------- 1 | package sanity 2 | 3 | type Callbacks struct { 4 | OnErrorWillRetry func(error) 5 | OnQueryResult func(*QueryResult) 6 | } 7 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | package sanity 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io/ioutil" 9 | "net/http" 10 | "net/url" 11 | "regexp" 12 | "runtime" 13 | "time" 14 | 15 | "github.com/jpillora/backoff" 16 | 17 | "github.com/sanity-io/client-go/internal/requests" 18 | ) 19 | 20 | const ( 21 | // Default dataset name for sanity projects 22 | DefaultDataset = "production" 23 | 24 | // API Host for skipping CDN 25 | APIHost = "api.sanity.io" 26 | 27 | // API Host which connects through CDN 28 | APICDNHost = "apicdn.sanity.io" 29 | 30 | // VersionV1 is API version 1, the initial released version 31 | VersionV1 = Version("1") 32 | 33 | // VersionExperimental is the experimental API version 34 | VersionExperimental = Version("X") 35 | 36 | // Latest API version release 37 | VersionV20210325 = Version("2021-03-25") 38 | 39 | // Deprecated: VersionDefault is the API version used when client is 40 | // instantiated without any specific version. 41 | VersionDefault = VersionV1 42 | ) 43 | 44 | // Version is an API version, generally be dates in ISO format but also 45 | // "1" (for backwards compatibility) and "X" (for experimental features) 46 | type Version string 47 | 48 | // String implements fmt.Stringer. 49 | func (version Version) String() string { 50 | return string(version) 51 | } 52 | 53 | // Validate validates a version 54 | func (version Version) Validate() error { 55 | if version == "" { 56 | return errors.New("no version given") 57 | } 58 | regExpVersion := regexp.MustCompile(`^(1|X|\d{4}-\d{2}-\d{2})$`) 59 | if !regExpVersion.MatchString(string(version)) { 60 | return fmt.Errorf("invalid version format %q", version) 61 | } 62 | return nil 63 | } 64 | 65 | // Client implements a client for interacting with the Sanity API. 66 | type Client struct { 67 | hc *http.Client 68 | apiVersion Version 69 | useCDN bool 70 | baseAPIURL url.URL 71 | baseQueryURL url.URL // if useCDN=false, baseQueryURL will be same as baseAPIURL. 72 | customHeaders http.Header 73 | token string 74 | projectID string 75 | dataset string 76 | backoff backoff.Backoff 77 | callbacks Callbacks 78 | setHeaders func(r *requests.Request) 79 | tag string 80 | } 81 | 82 | type Option func(c *Client) 83 | 84 | // WithHTTPClient returns an option for setting a custom HTTP client. 85 | func WithHTTPClient(client *http.Client) Option { 86 | return func(c *Client) { c.hc = client } 87 | } 88 | 89 | // WithCallbacks returns an option that enables callbacks for common events 90 | // such as errors. 91 | func WithCallbacks(cbs Callbacks) Option { 92 | return func(c *Client) { c.callbacks = cbs } 93 | } 94 | 95 | // WithBackoff returns an option that configures network request backoff. For how 96 | // backoff works, see the underlying backoff package: https://github.com/jpillora/backoff. 97 | // By default, the client uses the backoff package's default (maximum 10 seconds wait, 98 | // backoff factor of 2). 99 | func WithBackoff(b backoff.Backoff) Option { 100 | return func(c *Client) { c.backoff = b } 101 | } 102 | 103 | // WithToken returns an option that sets the API token to use. 104 | func WithToken(t string) Option { 105 | return func(c *Client) { c.token = t } 106 | } 107 | 108 | // WithCDN returns an option that enables or disables the use of the Sanity API CDN. 109 | // It is ignored when a custom HTTP host is set. 110 | func WithCDN(b bool) Option { 111 | return func(c *Client) { c.useCDN = b } 112 | } 113 | 114 | // WithHTTPHost returns an option that changes the API URL. 115 | func WithHTTPHost(scheme, host string) Option { 116 | return func(c *Client) { 117 | c.baseAPIURL.Scheme = scheme 118 | c.baseAPIURL.Host = host 119 | c.baseQueryURL.Scheme = scheme 120 | c.baseQueryURL.Host = host 121 | } 122 | } 123 | 124 | // WithHTTPHeader returns an option for setting a custom HTTP header. 125 | // These headers are set in addition to the ones defined in Client.setHeaders(). 126 | // If a custom header is added with the same key as one of default header, then 127 | // custom value is appended to key, and does not replace default value. 128 | func WithHTTPHeader(key, value string) Option { 129 | return func(c *Client) { 130 | if c.customHeaders == nil { 131 | c.customHeaders = make(http.Header) 132 | } 133 | c.customHeaders.Add(key, value) 134 | } 135 | } 136 | 137 | // WithTag returns an option for setting the default tag to set on all requests. 138 | func WithTag(t string) Option { 139 | return func(c *Client) { c.tag = t } 140 | } 141 | 142 | // Deprecated: Use version.NewClient() instead. 143 | // New returns a new client with a default API version. A project ID must be provided. 144 | // Zero or more options can be passed. For example: 145 | // 146 | // client := sanity.New("projectId", sanity.DefaultDataset, 147 | // sanity.WithCDN(true), sanity.WithToken("mytoken")) 148 | func New(projectID, dataset string, opts ...Option) (*Client, error) { 149 | return VersionDefault.NewClient(projectID, dataset, opts...) 150 | } 151 | 152 | // NewClient returns a new versioned client. A project ID must be provided. 153 | // Zero or more options can be passed. For example: 154 | // 155 | // client := sanity.VersionV20210325.NewClient("projectId", sanity.DefaultDataset, 156 | // sanity.WithCDN(true), sanity.WithToken("mytoken")) 157 | // 158 | func (v Version) NewClient(projectID, dataset string, opts ...Option) (*Client, error) { 159 | if projectID == "" { 160 | return nil, errors.New("project ID cannot be empty") 161 | } 162 | 163 | if dataset == "" { 164 | return nil, errors.New("dataset must be set") 165 | } 166 | 167 | baseAPIURL := fmt.Sprintf("%s.%s", projectID, APIHost) 168 | c := Client{ 169 | backoff: backoff.Backoff{Jitter: true}, 170 | hc: http.DefaultClient, 171 | projectID: projectID, 172 | dataset: dataset, 173 | apiVersion: v, 174 | baseAPIURL: url.URL{ 175 | Scheme: "https", 176 | Host: baseAPIURL, 177 | Path: fmt.Sprintf("/v%s", v.String()), 178 | }, 179 | } 180 | 181 | for _, opt := range opts { 182 | opt(&c) 183 | } 184 | 185 | c.baseQueryURL = c.baseAPIURL 186 | // Only use APICDN if useCDN=true and API host has not been updated by options. 187 | if c.useCDN && c.baseAPIURL.Host == baseAPIURL { 188 | c.baseQueryURL.Host = fmt.Sprintf("%s.%s", projectID, APICDNHost) 189 | } 190 | 191 | setDefaultHeaders := func(r *requests.Request) { 192 | r.Header("user-agent", "Sanity Go client/"+runtime.Version()) 193 | if c.token != "" { 194 | r.Header("authorization", "Bearer "+c.token) 195 | } 196 | } 197 | 198 | c.setHeaders = func(r *requests.Request) { 199 | setDefaultHeaders(r) 200 | for key, values := range c.customHeaders { 201 | for _, value := range values { 202 | r.Header(key, value) 203 | } 204 | } 205 | } 206 | 207 | return &c, nil 208 | } 209 | 210 | func (c *Client) do(ctx context.Context, r *requests.Request, dest interface{}) (*http.Response, error) { 211 | req, err := r.HTTPRequest() 212 | if err != nil { 213 | return nil, err 214 | } 215 | 216 | // Workaround for setting custom host header which is overridden after req.Header.Add() 217 | // See: https://github.com/golang/go/issues/29865 218 | if host := req.Header.Get("host"); host != "" { 219 | req.Host = host 220 | } 221 | 222 | if req.Method == http.MethodGet && len(r.EncodeURL()) > maxGETRequestURLLength { 223 | return nil, errors.New("max URL length exceeded in GET request") 224 | } 225 | 226 | req = req.WithContext(ctx) 227 | bckoff := c.backoff 228 | for { 229 | resp, err := c.hc.Do(req) 230 | if err != nil { 231 | return nil, fmt.Errorf("[%s %s] failed: %w", req.Method, req.URL.String(), err) 232 | } 233 | 234 | defer func() { 235 | _ = resp.Body.Close() 236 | }() 237 | 238 | if resp.StatusCode >= 200 && resp.StatusCode <= 299 { 239 | return resp, json.NewDecoder(resp.Body).Decode(dest) 240 | } 241 | 242 | if !isMethodRetriable(req.Method) || !isStatusCodeRetriable(resp.StatusCode) { 243 | return nil, c.handleErrorResponse(req, resp) 244 | } 245 | 246 | _ = resp.Body.Close() 247 | 248 | if c.callbacks.OnErrorWillRetry != nil { 249 | c.callbacks.OnErrorWillRetry(err) 250 | } 251 | 252 | time.Sleep(bckoff.Duration()) 253 | } 254 | } 255 | 256 | func (c *Client) handleErrorResponse(req *http.Request, resp *http.Response) error { 257 | body := []byte("[no response body]") 258 | 259 | if resp.Body != nil { 260 | var err error 261 | if body, err = ioutil.ReadAll(resp.Body); err != nil { 262 | body = []byte(fmt.Sprintf("[failed to read response body: %s]", err)) 263 | } 264 | } 265 | 266 | return &RequestError{ 267 | Request: req, 268 | Response: resp, 269 | Body: body, 270 | } 271 | } 272 | 273 | func (c *Client) newAPIRequest() *requests.Request { 274 | r := requests.New(c.baseAPIURL) 275 | c.setHeaders(r) 276 | return r 277 | } 278 | 279 | func (c *Client) newQueryRequest() *requests.Request { 280 | r := requests.New(c.baseQueryURL) 281 | c.setHeaders(r) 282 | return r 283 | } 284 | 285 | const maxGETRequestURLLength = 1024 286 | -------------------------------------------------------------------------------- /client_test.go: -------------------------------------------------------------------------------- 1 | package sanity_test 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | 11 | sanity "github.com/sanity-io/client-go" 12 | ) 13 | 14 | func TestAuthorization(t *testing.T) { 15 | withSuite(t, func(s *Suite) { 16 | s.mux.Get("/v1/data/query/myDataset", func(w http.ResponseWriter, r *http.Request) { 17 | assert.Equal(t, "Bearer bork", r.Header.Get("Authorization")) 18 | 19 | _, err := w.Write([]byte("{}")) 20 | assert.NoError(t, err) 21 | }) 22 | 23 | _, err := s.client.Query("*").Do(context.Background()) 24 | require.NoError(t, err) 25 | }, 26 | sanity.WithToken("bork"), 27 | ) 28 | } 29 | 30 | func TestCustomHeaders(t *testing.T) { 31 | withSuite(t, func(s *Suite) { 32 | s.mux.Get("/v1/data/query/myDataset", func(w http.ResponseWriter, r *http.Request) { 33 | assert.Equal(t, "bar", r.Header.Get("foo")) 34 | assert.Equal(t, []string{"application/json", "text/xml"}, r.Header.Values("accept")) 35 | assert.Equal(t, "sanity.io", r.Host) 36 | 37 | _, err := w.Write([]byte("{}")) 38 | assert.NoError(t, err) 39 | }) 40 | 41 | _, err := s.client.Query("*").Do(context.Background()) 42 | require.NoError(t, err) 43 | }, 44 | sanity.WithHTTPHeader("foo", "bar"), 45 | sanity.WithHTTPHeader("foo", "baz"), // Should be ignored 46 | sanity.WithHTTPHeader("accept", "text/xml"), 47 | sanity.WithHTTPHeader("host", "sanity.io"), 48 | ) 49 | } 50 | 51 | func TestVersion_Validate(t *testing.T) { 52 | tests := []struct { 53 | name string 54 | version sanity.Version 55 | wantErr bool 56 | }{ 57 | { 58 | name: "empty string", 59 | version: sanity.Version(""), 60 | wantErr: true, 61 | }, 62 | { 63 | name: "invalid date format", 64 | version: sanity.Version("2021-01"), 65 | wantErr: true, 66 | }, 67 | { 68 | name: "invalid date format", 69 | version: sanity.Version("20210101"), 70 | wantErr: true, 71 | }, 72 | { 73 | name: "valid date version", 74 | version: sanity.Version("2021-01-01"), 75 | wantErr: false, 76 | }, 77 | } 78 | 79 | for _, tt := range tests { 80 | t.Run(tt.name, func(t *testing.T) { 81 | if err := tt.version.Validate(); (err != nil) != tt.wantErr { 82 | t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) 83 | } 84 | }) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /errors.go: -------------------------------------------------------------------------------- 1 | package sanity 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | ) 7 | 8 | // RequestError is returned for API requests that fail with a non-successful HTTP status code. 9 | type RequestError struct { 10 | // Request is the attempted HTTP request that failed. 11 | Request *http.Request 12 | 13 | // Response is the HTTP response. Note that the body will no longer be valid. 14 | Response *http.Response 15 | 16 | // Body is the body of the response. 17 | Body []byte 18 | } 19 | 20 | // Error implements the error interface. 21 | func (e *RequestError) Error() string { 22 | maxBody := 500 23 | body := string(e.Body) 24 | if len(body) > maxBody { 25 | body = fmt.Sprintf("%s [... and %d more bytes]", body[0:maxBody], len(body)-maxBody) 26 | } 27 | 28 | msg := fmt.Sprintf("HTTP request [%s %s] failed with status %d", 29 | e.Request.Method, e.Request.URL.String(), e.Response.StatusCode) 30 | if body != "" { 31 | msg += ": " + body 32 | } 33 | return msg 34 | } 35 | -------------------------------------------------------------------------------- /get_documents.go: -------------------------------------------------------------------------------- 1 | package sanity 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | 7 | "github.com/sanity-io/client-go/api" 8 | ) 9 | 10 | // GetDocuments returns a new GetDocuments builder. 11 | func (c *Client) GetDocuments(docIDs ...string) *GetDocumentsBuilder { 12 | return &GetDocumentsBuilder{c: c, docIDs: docIDs} 13 | } 14 | 15 | // QueryBuilder is a builder for GET documents API. 16 | type GetDocumentsBuilder struct { 17 | c *Client 18 | docIDs []string 19 | tag string 20 | } 21 | 22 | func (b *GetDocumentsBuilder) Tag(tag string) *GetDocumentsBuilder { 23 | b.tag = tag 24 | return b 25 | } 26 | 27 | // Do performs the query. 28 | // On API request failure, this will return an error of type *RequestError. 29 | func (b *GetDocumentsBuilder) Do(ctx context.Context) (*api.GetDocumentsResponse, error) { 30 | if len(b.docIDs) == 0 { 31 | return &api.GetDocumentsResponse{}, nil 32 | } 33 | 34 | req := b.c.newAPIRequest(). 35 | AppendPath("data/doc", b.c.dataset, strings.Join(b.docIDs, ",")). 36 | Tag(b.tag, b.c.tag) 37 | 38 | var resp api.GetDocumentsResponse 39 | if _, err := b.c.do(ctx, req, &resp); err != nil { 40 | return nil, err 41 | } 42 | 43 | return &resp, nil 44 | } 45 | -------------------------------------------------------------------------------- /get_documents_test.go: -------------------------------------------------------------------------------- 1 | package sanity_test 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net/http" 7 | "testing" 8 | "time" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | 13 | sanity "github.com/sanity-io/client-go" 14 | "github.com/sanity-io/client-go/api" 15 | ) 16 | 17 | func TestGetDocuments(t *testing.T) { 18 | docIDs := []string{"doc1", "doc2"} 19 | now := time.Date(2020, 1, 2, 23, 01, 44, 0, time.UTC) 20 | testDoc1 := &testDocument{ 21 | ID: "doc1", 22 | Type: "doc", 23 | CreatedAt: now, 24 | UpdatedAt: now, 25 | Value: "hello world", 26 | } 27 | 28 | testDoc2 := &testDocument{ 29 | ID: "doc2", 30 | Type: "doc", 31 | CreatedAt: now, 32 | UpdatedAt: now, 33 | Value: "hello world", 34 | } 35 | 36 | testDocuments := []api.Document{testDoc1.toMap(), testDoc2.toMap()} 37 | 38 | t.Run("No document ID specified", func(t *testing.T) { 39 | withSuite(t, func(s *Suite) { 40 | resp, err := s.client.GetDocuments().Do(context.Background()) 41 | require.NoError(t, err) 42 | require.Nil(t, resp.Documents) 43 | }) 44 | }) 45 | 46 | t.Run("Empty document ID specified", func(t *testing.T) { 47 | withSuite(t, func(s *Suite) { 48 | s.mux.Get("/v1/data/doc/myDataset", func(w http.ResponseWriter, r *http.Request) { 49 | w.WriteHeader(http.StatusNotFound) 50 | }) 51 | 52 | _, err := s.client.GetDocuments([]string{""}...).Do(context.Background()) 53 | require.Error(t, err) 54 | 55 | var reqErr *sanity.RequestError 56 | require.True(t, errors.As(err, &reqErr)) 57 | }) 58 | }) 59 | 60 | t.Run("GET URL length exceeded", func(t *testing.T) { 61 | withSuite(t, func(s *Suite) { 62 | docID := make([]rune, 1024) 63 | for i := range docID { 64 | docID[i] = 'x' 65 | } 66 | _, err := s.client.GetDocuments(string(docID)).Do(context.Background()) 67 | require.Error(t, err) 68 | }) 69 | }) 70 | 71 | t.Run("get 2 documents", func(t *testing.T) { 72 | withSuite(t, func(s *Suite) { 73 | s.mux.Get("/v1/data/doc/myDataset/doc1,doc2", func(w http.ResponseWriter, r *http.Request) { 74 | w.WriteHeader(http.StatusOK) 75 | _, err := w.Write(mustJSONBytes(&api.GetDocumentsResponse{ 76 | Documents: testDocuments, 77 | })) 78 | assert.NoError(t, err) 79 | }) 80 | 81 | result, err := s.client.GetDocuments(docIDs...).Do(context.Background()) 82 | require.NoError(t, err) 83 | 84 | assert.Equal(t, testDocuments, result.Documents) 85 | }) 86 | }) 87 | 88 | t.Run("supports default tag", func(t *testing.T) { 89 | withSuite(t, func(s *Suite) { 90 | s.mux.Get("/v1/data/doc/myDataset", func(w http.ResponseWriter, r *http.Request) { 91 | assert.Equal(t, "default", r.URL.Query().Get("tag")) 92 | w.WriteHeader(http.StatusNotFound) 93 | }) 94 | _, err := s.client.GetDocuments([]string{""}...).Do(context.Background()) 95 | require.Error(t, err) 96 | }, sanity.WithTag("default")) 97 | }) 98 | 99 | t.Run("supports overwriting tag", func(t *testing.T) { 100 | withSuite(t, func(s *Suite) { 101 | s.mux.Get("/v1/data/doc/myDataset", func(w http.ResponseWriter, r *http.Request) { 102 | assert.Equal(t, "custom", r.URL.Query().Get("tag")) 103 | w.WriteHeader(http.StatusNotFound) 104 | }) 105 | _, err := s.client.GetDocuments([]string{""}...).Tag("custom").Do(context.Background()) 106 | require.Error(t, err) 107 | }, sanity.WithTag("tag")) 108 | }) 109 | } 110 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/sanity-io/client-go 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/go-chi/chi v1.5.1 7 | github.com/jpillora/backoff v0.0.0-20180909062703-3050d21c67d7 8 | github.com/stretchr/testify v1.6.1 9 | ) 10 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/go-chi/chi v1.5.1 h1:kfTK3Cxd/dkMu/rKs5ZceWYp+t5CtiE7vmaTv3LjC6w= 4 | github.com/go-chi/chi v1.5.1/go.mod h1:REp24E+25iKvxgeTfHmdUoL5x15kBiDBlnIl5bCwe2k= 5 | github.com/jpillora/backoff v0.0.0-20180909062703-3050d21c67d7 h1:K//n/AqR5HjG3qxbrBCL4vJPW0MVFSs9CPK1OOJdRME= 6 | github.com/jpillora/backoff v0.0.0-20180909062703-3050d21c67d7/go.mod h1:2iMrUgbbvHEiQClaW2NsSzMyGHqN+rDFqY705q49KG0= 7 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 8 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 9 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 10 | github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= 11 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 12 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 13 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 14 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 15 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 16 | -------------------------------------------------------------------------------- /internal/requests/requests.go: -------------------------------------------------------------------------------- 1 | package requests 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "net/url" 10 | ) 11 | 12 | type Request struct { 13 | baseURL url.URL 14 | path string 15 | method string 16 | params url.Values 17 | body io.Reader 18 | headers http.Header 19 | maxResponseSize int64 20 | err error 21 | } 22 | 23 | func New(baseURL url.URL) *Request { 24 | return &Request{ 25 | baseURL: baseURL, 26 | method: http.MethodGet, 27 | headers: http.Header{ 28 | "Accept": []string{"application/json"}, 29 | }, 30 | } 31 | } 32 | 33 | func (b *Request) HTTPRequest() (*http.Request, error) { 34 | if b.err != nil { 35 | return nil, b.err 36 | } 37 | 38 | req, err := http.NewRequest(b.method, b.EncodeURL(), b.body) 39 | if err != nil { 40 | return nil, err 41 | } 42 | 43 | for k, v := range b.headers { 44 | req.Header[k] = v 45 | } 46 | return req, nil 47 | } 48 | 49 | func (b *Request) EncodeURL() string { 50 | u := b.baseURL 51 | u.Path += b.path 52 | if b.params != nil { 53 | u.RawQuery = b.params.Encode() 54 | } 55 | return u.String() 56 | } 57 | 58 | func (b *Request) Method(m string) *Request { 59 | b.method = m 60 | return b 61 | } 62 | 63 | func (b *Request) Path(elems ...string) *Request { 64 | b.path = "" 65 | return b.AppendPath(elems...) 66 | } 67 | 68 | func (b *Request) AppendPath(elems ...string) *Request { 69 | for _, elem := range elems { 70 | if (b.path == "" || b.path[len(b.path)-1] != '/') && 71 | (len(elem) > 0 && elem[0] != '/') { 72 | b.path += "/" 73 | } 74 | b.path += elem 75 | } 76 | return b 77 | } 78 | 79 | func (b *Request) Header(name, val string) *Request { 80 | if b.headers == nil { 81 | b.headers = make(http.Header, 10) // Small capacity 82 | } 83 | b.headers.Add(name, val) 84 | return b 85 | } 86 | 87 | func (b *Request) Param(name string, val interface{}) *Request { 88 | if b.params == nil { 89 | b.params = make(url.Values, 10) // Small capacity 90 | } 91 | 92 | switch val := val.(type) { 93 | case string: 94 | b.params.Add(name, val) 95 | case fmt.Stringer: 96 | b.params.Add(name, val.String()) 97 | case bool: 98 | if val { 99 | b.params.Add(name, "true") 100 | } else { 101 | b.params.Add(name, "false") 102 | } 103 | default: 104 | panic(fmt.Sprintf("cannot add %q of type %T as parameter", name, val)) 105 | } 106 | return b 107 | } 108 | 109 | func (b *Request) Tag(tag string, defaultTag string) *Request { 110 | if tag != "" { 111 | b.Param("tag", tag) 112 | } else if defaultTag != "" { 113 | b.Param("tag", defaultTag) 114 | } 115 | return b 116 | } 117 | 118 | func (b *Request) MaxResponseSize(limit int64) *Request { 119 | b.maxResponseSize = limit 120 | return b 121 | } 122 | 123 | func (b *Request) Body(body []byte) *Request { 124 | b.body = bytes.NewReader(body) 125 | return b 126 | } 127 | 128 | func (b *Request) ReadBody(r io.Reader) *Request { 129 | b.body = r 130 | return b 131 | } 132 | 133 | func (b *Request) MarshalBody(val interface{}) *Request { 134 | body, err := json.Marshal(val) 135 | if err != nil { 136 | b.err = fmt.Errorf("marshaling body value to JSON: %w", err) 137 | return b 138 | } 139 | 140 | b.body = bytes.NewReader(body) 141 | return b 142 | } 143 | -------------------------------------------------------------------------------- /internal/requests/requests_test.go: -------------------------------------------------------------------------------- 1 | package requests_test 2 | 3 | import ( 4 | "net/url" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | 9 | "github.com/sanity-io/client-go/internal/requests" 10 | ) 11 | 12 | func TestRequest_AppendPath(t *testing.T) { 13 | tests := []struct { 14 | name string 15 | elems []string 16 | want string 17 | }{ 18 | { 19 | name: "empty string", 20 | elems: []string{""}, 21 | want: "//localhost", 22 | }, 23 | { 24 | name: "with path", 25 | elems: []string{"foo"}, 26 | want: "//localhost/foo", 27 | }, 28 | { 29 | name: "with multiple paths", 30 | elems: []string{"foo", "bar"}, 31 | want: "//localhost/foo/bar", 32 | }, 33 | { 34 | name: "with multiple paths and empty string", 35 | elems: []string{"foo", "", "bar"}, 36 | want: "//localhost/foo/bar", 37 | }, 38 | { 39 | name: "with empty string at the start", 40 | elems: []string{"", "foo", "bar"}, 41 | want: "//localhost/foo/bar", 42 | }, 43 | { 44 | name: "with empty string at the end", 45 | elems: []string{"foo", "bar", ""}, 46 | want: "//localhost/foo/bar", 47 | }, 48 | { 49 | name: "with slash in path string", 50 | elems: []string{"foo", "/", "bar"}, 51 | want: "//localhost/foo/bar", 52 | }, 53 | { 54 | name: "with slash in path string at the start", 55 | elems: []string{"/", "foo", "bar"}, 56 | want: "//localhost/foo/bar", 57 | }, 58 | { 59 | name: "with slash in path string at the end", 60 | elems: []string{"foo", "/", "bar"}, 61 | want: "//localhost/foo/bar", 62 | }, 63 | } 64 | baseURL := url.URL{Host: "localhost"} 65 | for _, tt := range tests { 66 | t.Run(tt.name, func(t *testing.T) { 67 | r := requests.New(baseURL) 68 | got := r.AppendPath(tt.elems...) 69 | require.Equal(t, got.EncodeURL(), tt.want) 70 | }) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /mutation.go: -------------------------------------------------------------------------------- 1 | package sanity 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | 9 | "github.com/sanity-io/client-go/api" 10 | ) 11 | 12 | // Mutate returns a new mutation builder. 13 | func (c *Client) Mutate() *MutationBuilder { 14 | return &MutationBuilder{ 15 | c: c, 16 | returnDocs: true, 17 | visibility: api.MutationVisibilitySync, 18 | } 19 | } 20 | 21 | type MutateResult struct { 22 | TransactionID string 23 | Results []*api.MutateResultItem 24 | } 25 | 26 | type MutationBuilder struct { 27 | c *Client 28 | items []*api.MutationItem 29 | err error 30 | returnIDs bool 31 | returnDocs bool 32 | visibility api.MutationVisibility 33 | transactionID string 34 | dryRun bool 35 | tag string 36 | } 37 | 38 | func (mb *MutationBuilder) Visibility(v api.MutationVisibility) *MutationBuilder { 39 | mb.visibility = v 40 | return mb 41 | } 42 | 43 | func (mb *MutationBuilder) TransactionID(id string) *MutationBuilder { 44 | mb.transactionID = id 45 | return mb 46 | } 47 | 48 | func (mb *MutationBuilder) ReturnIDs(enable bool) *MutationBuilder { 49 | mb.returnIDs = enable 50 | return mb 51 | } 52 | 53 | func (mb *MutationBuilder) ReturnDocuments(enable bool) *MutationBuilder { 54 | mb.returnDocs = enable 55 | return mb 56 | } 57 | 58 | func (mb *MutationBuilder) DryRun(enable bool) *MutationBuilder { 59 | mb.dryRun = enable 60 | return mb 61 | } 62 | 63 | func (mb *MutationBuilder) Tag(val string) *MutationBuilder { 64 | mb.tag = val 65 | return mb 66 | } 67 | 68 | func (mb *MutationBuilder) Do(ctx context.Context) (*MutateResult, error) { 69 | if mb.err != nil { 70 | return nil, fmt.Errorf("mutation builder: %w", mb.err) 71 | } 72 | 73 | req := mb.c.newAPIRequest(). 74 | Method(http.MethodPost). 75 | AppendPath("data/mutate", mb.c.dataset). 76 | Param("returnIds", mb.returnIDs). 77 | Param("returnDocuments", mb.returnDocs). 78 | Param("visibility", string(mb.visibility)). 79 | Param("dryRun", mb.dryRun). 80 | MarshalBody(&api.MutateRequest{Mutations: mb.items}). 81 | Tag(mb.tag, mb.c.tag) 82 | if mb.transactionID != "" { 83 | req.Param("transactionId", mb.transactionID) 84 | } 85 | 86 | var resp api.MutateResponse 87 | if _, err := mb.c.do(ctx, req, &resp); err != nil { 88 | return nil, fmt.Errorf("mutate: %w", err) 89 | } 90 | 91 | return &MutateResult{ 92 | TransactionID: resp.TransactionID, 93 | Results: resp.Results, 94 | }, nil 95 | } 96 | 97 | func (mb *MutationBuilder) Create(doc interface{}) *MutationBuilder { 98 | b, ok := mb.marshalJSON(doc) 99 | if ok { 100 | mb.items = append(mb.items, &api.MutationItem{Create: b}) 101 | } 102 | return mb 103 | } 104 | 105 | func (mb *MutationBuilder) CreateIfNotExists(doc interface{}) *MutationBuilder { 106 | b, ok := mb.marshalJSON(doc) 107 | if ok { 108 | mb.items = append(mb.items, &api.MutationItem{CreateIfNotExists: b}) 109 | } 110 | return mb 111 | } 112 | 113 | func (mb *MutationBuilder) CreateOrReplace(doc interface{}) *MutationBuilder { 114 | b, ok := mb.marshalJSON(doc) 115 | if ok { 116 | mb.items = append(mb.items, &api.MutationItem{CreateOrReplace: b}) 117 | } 118 | return mb 119 | } 120 | 121 | func (mb *MutationBuilder) Delete(id string) *MutationBuilder { 122 | mb.items = append(mb.items, &api.MutationItem{Delete: &api.Delete{ID: id}}) 123 | return mb 124 | } 125 | 126 | func (mb *MutationBuilder) Patch(id string) *PatchBuilder { 127 | patch := &api.Patch{ID: id} 128 | mb.items = append(mb.items, &api.MutationItem{Patch: patch}) 129 | return &PatchBuilder{mb, patch} 130 | } 131 | 132 | func (mb *MutationBuilder) setErr(err error) { 133 | if mb.err == nil { 134 | mb.err = err 135 | } 136 | } 137 | 138 | func (mb *MutationBuilder) marshalJSON(val interface{}) (*json.RawMessage, bool) { 139 | b, err := marshalJSON(val) 140 | if err != nil { 141 | mb.setErr(fmt.Errorf("marshaling document: %w", err)) 142 | return nil, false 143 | } 144 | 145 | return b, true 146 | } 147 | 148 | type PatchBuilder struct { 149 | mb *MutationBuilder 150 | patch *api.Patch 151 | } 152 | 153 | func (pb *PatchBuilder) IfRevisionID(id string) *PatchBuilder { 154 | pb.patch.IfRevisionID = id 155 | return pb 156 | } 157 | 158 | func (pb *PatchBuilder) Query(query string) *PatchBuilder { 159 | pb.patch.Query = query 160 | return pb 161 | } 162 | 163 | func (pb *PatchBuilder) Set(path string, val interface{}) *PatchBuilder { 164 | if pb.patch.Set == nil { 165 | pb.patch.Set = map[string]*json.RawMessage{} 166 | } 167 | 168 | b, ok := pb.mb.marshalJSON(val) 169 | if ok { 170 | pb.patch.Set[path] = b 171 | } 172 | 173 | return pb 174 | } 175 | 176 | func (pb *PatchBuilder) SetIfMissing(path string, val interface{}) *PatchBuilder { 177 | if pb.patch.SetIfMissing == nil { 178 | pb.patch.SetIfMissing = map[string]*json.RawMessage{} 179 | } 180 | 181 | b, ok := pb.mb.marshalJSON(val) 182 | if ok { 183 | pb.patch.SetIfMissing[path] = b 184 | } 185 | 186 | return pb 187 | } 188 | 189 | func (pb *PatchBuilder) Unset(paths ...string) *PatchBuilder { 190 | pb.patch.Unset = append(pb.patch.Unset, paths...) 191 | return pb 192 | } 193 | 194 | func (pb *PatchBuilder) Inc(path string, n float64) *PatchBuilder { 195 | if pb.patch.Inc == nil { 196 | pb.patch.Inc = map[string]float64{} 197 | } 198 | 199 | pb.patch.Inc[path] = n 200 | return pb 201 | } 202 | 203 | func (pb *PatchBuilder) Dec(path string, n float64) *PatchBuilder { 204 | if pb.patch.Dec == nil { 205 | pb.patch.Dec = map[string]float64{} 206 | } 207 | 208 | pb.patch.Dec[path] = n 209 | return pb 210 | } 211 | 212 | func (pb *PatchBuilder) InsertBefore(path string, items ...interface{}) *PatchBuilder { 213 | bs := make([]*json.RawMessage, len(items)) 214 | for i, item := range items { 215 | b, ok := pb.mb.marshalJSON(item) 216 | if !ok { 217 | return pb 218 | } 219 | bs[i] = b 220 | } 221 | 222 | pb.patch.Insert = &api.Insert{ 223 | Before: path, 224 | Items: bs, 225 | } 226 | return pb 227 | } 228 | 229 | func (pb *PatchBuilder) InsertAfter(path string, items ...interface{}) *PatchBuilder { 230 | bs := make([]*json.RawMessage, len(items)) 231 | for i, item := range items { 232 | b, ok := pb.mb.marshalJSON(item) 233 | if !ok { 234 | return pb 235 | } 236 | bs[i] = b 237 | } 238 | 239 | pb.patch.Insert = &api.Insert{ 240 | After: path, 241 | Items: bs, 242 | } 243 | return pb 244 | } 245 | 246 | func (pb *PatchBuilder) InsertReplace(path string, items ...interface{}) *PatchBuilder { 247 | bs := make([]*json.RawMessage, len(items)) 248 | for i, item := range items { 249 | b, ok := pb.mb.marshalJSON(item) 250 | if !ok { 251 | return pb 252 | } 253 | bs[i] = b 254 | } 255 | 256 | pb.patch.Insert = &api.Insert{ 257 | Replace: path, 258 | Items: bs, 259 | } 260 | return pb 261 | } 262 | 263 | func (pb *PatchBuilder) End() *MutationBuilder { 264 | return pb.mb 265 | } 266 | -------------------------------------------------------------------------------- /mutation_test.go: -------------------------------------------------------------------------------- 1 | package sanity_test 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io/ioutil" 9 | "net/http" 10 | "testing" 11 | "time" 12 | 13 | "github.com/stretchr/testify/assert" 14 | "github.com/stretchr/testify/require" 15 | 16 | sanity "github.com/sanity-io/client-go" 17 | "github.com/sanity-io/client-go/api" 18 | ) 19 | 20 | func TestMutation_Builder(t *testing.T) { 21 | now := time.Date(2020, 1, 2, 23, 01, 44, 0, time.UTC) 22 | 23 | testDoc := &testDocument{ 24 | ID: "123", 25 | Type: "doc", 26 | CreatedAt: now, 27 | UpdatedAt: now, 28 | Value: "hello world", 29 | } 30 | 31 | for _, tc := range []struct { 32 | desc string 33 | buildFunc func(b *sanity.MutationBuilder) 34 | expect api.MutateRequest 35 | }{ 36 | { 37 | "Create", 38 | func(b *sanity.MutationBuilder) { 39 | b.Create(testDoc) 40 | }, 41 | api.MutateRequest{ 42 | Mutations: []*api.MutationItem{{Create: mustJSONMsg(testDoc)}}, 43 | }, 44 | }, 45 | { 46 | "CreateIfNotExists", 47 | func(b *sanity.MutationBuilder) { 48 | b.CreateIfNotExists(testDoc) 49 | }, 50 | api.MutateRequest{ 51 | Mutations: []*api.MutationItem{{CreateIfNotExists: mustJSONMsg(testDoc)}}, 52 | }, 53 | }, 54 | { 55 | "CreateOrReplace", 56 | func(b *sanity.MutationBuilder) { 57 | b.CreateOrReplace(testDoc) 58 | }, 59 | api.MutateRequest{ 60 | Mutations: []*api.MutationItem{{CreateOrReplace: mustJSONMsg(testDoc)}}, 61 | }, 62 | }, 63 | { 64 | "Delete", 65 | func(b *sanity.MutationBuilder) { 66 | b.Delete("123") 67 | }, 68 | api.MutateRequest{ 69 | Mutations: []*api.MutationItem{{Delete: &api.Delete{ID: "123"}}}, 70 | }, 71 | }, 72 | { 73 | "empty patch", 74 | func(b *sanity.MutationBuilder) { 75 | b.Patch("123") 76 | }, 77 | api.MutateRequest{ 78 | Mutations: []*api.MutationItem{{Patch: &api.Patch{ 79 | ID: "123", 80 | }}}, 81 | }, 82 | }, 83 | { 84 | "patch with End", 85 | func(b *sanity.MutationBuilder) { 86 | b.Patch("123").End().Patch("234") 87 | }, 88 | api.MutateRequest{ 89 | Mutations: []*api.MutationItem{ 90 | {Patch: &api.Patch{ID: "123"}}, 91 | {Patch: &api.Patch{ID: "234"}}, 92 | }, 93 | }, 94 | }, 95 | { 96 | "Patch/IfRevisionID", 97 | func(b *sanity.MutationBuilder) { 98 | b.Patch("123").IfRevisionID("foo").Inc("values[0]", 1) 99 | }, 100 | api.MutateRequest{ 101 | Mutations: []*api.MutationItem{{Patch: &api.Patch{ 102 | ID: "123", 103 | IfRevisionID: "foo", 104 | Inc: map[string]float64{"values[0]": 1}, 105 | }}}, 106 | }, 107 | }, 108 | { 109 | "Patch/Query", 110 | func(b *sanity.MutationBuilder) { 111 | b.Patch("123").Query("*").Inc("values[0]", 1) 112 | }, 113 | api.MutateRequest{ 114 | Mutations: []*api.MutationItem{{Patch: &api.Patch{ 115 | ID: "123", 116 | Query: "*", 117 | Inc: map[string]float64{"values[0]": 1}, 118 | }}}, 119 | }, 120 | }, 121 | { 122 | "Patch/Inc", 123 | func(b *sanity.MutationBuilder) { 124 | b.Patch("123").Inc("values[0]", 1) 125 | }, 126 | api.MutateRequest{ 127 | Mutations: []*api.MutationItem{{Patch: &api.Patch{ 128 | ID: "123", 129 | Inc: map[string]float64{"values[0]": 1}, 130 | }}}, 131 | }, 132 | }, 133 | { 134 | "Patch/Dec", 135 | func(b *sanity.MutationBuilder) { 136 | b.Patch("123").Dec("values[0]", 1) 137 | }, 138 | api.MutateRequest{ 139 | Mutations: []*api.MutationItem{{Patch: &api.Patch{ 140 | ID: "123", 141 | Dec: map[string]float64{"values[0]": 1}, 142 | }}}, 143 | }, 144 | }, 145 | { 146 | "Patch/Set", 147 | func(b *sanity.MutationBuilder) { 148 | b.Patch("123").Set("a", testDoc) 149 | }, 150 | api.MutateRequest{ 151 | Mutations: []*api.MutationItem{{Patch: &api.Patch{ 152 | ID: "123", 153 | Set: map[string]*json.RawMessage{ 154 | "a": mustJSONMsg(testDoc), 155 | }, 156 | }}}, 157 | }, 158 | }, 159 | { 160 | "Patch/SetIfMissing", 161 | func(b *sanity.MutationBuilder) { 162 | b.Patch("123").SetIfMissing("a", testDoc) 163 | }, 164 | api.MutateRequest{ 165 | Mutations: []*api.MutationItem{{Patch: &api.Patch{ 166 | ID: "123", 167 | SetIfMissing: map[string]*json.RawMessage{ 168 | "a": mustJSONMsg(testDoc), 169 | }, 170 | }}}, 171 | }, 172 | }, 173 | { 174 | "Patch/Unset", 175 | func(b *sanity.MutationBuilder) { 176 | b.Patch("123").Unset("a", "b") 177 | }, 178 | api.MutateRequest{ 179 | Mutations: []*api.MutationItem{{Patch: &api.Patch{ 180 | ID: "123", 181 | Unset: []string{"a", "b"}, 182 | }}}, 183 | }, 184 | }, 185 | { 186 | "Patch/InsertAfter", 187 | func(b *sanity.MutationBuilder) { 188 | b.Patch("123").InsertAfter("array[3]", testDoc, "doink") 189 | }, 190 | api.MutateRequest{ 191 | Mutations: []*api.MutationItem{{Patch: &api.Patch{ 192 | ID: "123", 193 | Insert: &api.Insert{ 194 | After: "array[3]", 195 | Items: []*json.RawMessage{ 196 | mustJSONMsg(testDoc), 197 | mustJSONMsg("doink"), 198 | }, 199 | }, 200 | }}}, 201 | }, 202 | }, 203 | { 204 | "Patch/InsertBefore", 205 | func(b *sanity.MutationBuilder) { 206 | b.Patch("123").InsertBefore("array[3]", testDoc, "doink") 207 | }, 208 | api.MutateRequest{ 209 | Mutations: []*api.MutationItem{{Patch: &api.Patch{ 210 | ID: "123", 211 | Insert: &api.Insert{ 212 | Before: "array[3]", 213 | Items: []*json.RawMessage{ 214 | mustJSONMsg(testDoc), 215 | mustJSONMsg("doink"), 216 | }, 217 | }, 218 | }}}, 219 | }, 220 | }, 221 | { 222 | "Patch/InsertReplace", 223 | func(b *sanity.MutationBuilder) { 224 | b.Patch("123").InsertReplace("array[3]", testDoc, "doink") 225 | }, 226 | api.MutateRequest{ 227 | Mutations: []*api.MutationItem{{Patch: &api.Patch{ 228 | ID: "123", 229 | Insert: &api.Insert{ 230 | Replace: "array[3]", 231 | Items: []*json.RawMessage{ 232 | mustJSONMsg(testDoc), 233 | mustJSONMsg("doink"), 234 | }, 235 | }, 236 | }}}, 237 | }, 238 | }, 239 | } { 240 | t := t 241 | t.Run(tc.desc, func(t *testing.T) { 242 | withSuite(t, func(s *Suite) { 243 | s.mux.Post("/v1/data/mutate/myDataset", func(w http.ResponseWriter, r *http.Request) { 244 | var req api.MutateRequest 245 | require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) 246 | assert.Equal(t, tc.expect, req) 247 | 248 | w.WriteHeader(http.StatusOK) 249 | _, err := w.Write(mustJSONBytes(&api.MutateResponse{})) 250 | assert.NoError(t, err) 251 | }) 252 | 253 | builder := s.client.Mutate() 254 | tc.buildFunc(builder) 255 | 256 | _, err := builder.Do(context.Background()) 257 | require.NoError(t, err) 258 | }) 259 | }) 260 | } 261 | } 262 | 263 | func TestMutation_Builder_returnIDs(t *testing.T) { 264 | t.Run("can be set to true", func(t *testing.T) { 265 | withSuite(t, func(s *Suite) { 266 | s.mux.Post("/v1/data/mutate/myDataset", func(w http.ResponseWriter, r *http.Request) { 267 | assert.Equal(t, "true", r.URL.Query().Get("returnIds")) 268 | w.WriteHeader(http.StatusOK) 269 | _, err := w.Write(mustJSONBytes(&api.MutateResponse{})) 270 | assert.NoError(t, err) 271 | }) 272 | 273 | _, err := s.client.Mutate().ReturnIDs(true).Do(context.Background()) 274 | require.NoError(t, err) 275 | }) 276 | }) 277 | 278 | t.Run("defaults to false", func(t *testing.T) { 279 | withSuite(t, func(s *Suite) { 280 | s.mux.Post("/v1/data/mutate/myDataset", func(w http.ResponseWriter, r *http.Request) { 281 | assert.Equal(t, "true", r.URL.Query().Get("returnIds")) 282 | w.WriteHeader(http.StatusOK) 283 | _, err := w.Write(mustJSONBytes(&api.MutateResponse{})) 284 | assert.NoError(t, err) 285 | }) 286 | 287 | _, err := s.client.Mutate().ReturnIDs(true).Do(context.Background()) 288 | require.NoError(t, err) 289 | }) 290 | }) 291 | } 292 | 293 | func TestMutation_Builder_marshalError(t *testing.T) { 294 | withSuite(t, func(s *Suite) { 295 | _, err := s.client.Mutate().Create(&testDocumentWithJSONMarshalFailure{}).Do(context.Background()) 296 | require.Error(t, err) 297 | assert.True(t, errors.Is(err, errMarshalFailure)) 298 | }) 299 | } 300 | 301 | func TestMutation_Builder_customJSONMarshaling(t *testing.T) { 302 | t.Run("can be set", func(t *testing.T) { 303 | withSuite(t, func(s *Suite) { 304 | s.mux.Post("/v1/data/mutate/myDataset", func(w http.ResponseWriter, r *http.Request) { 305 | b, err := ioutil.ReadAll(r.Body) 306 | require.NoError(t, err) 307 | assert.Equal(t, `{"mutations":[{"create":{"x":1}}]}`, string(b)) 308 | 309 | w.WriteHeader(http.StatusOK) 310 | 311 | _, err = w.Write(mustJSONBytes(&api.MutateResponse{})) 312 | assert.NoError(t, err) 313 | }) 314 | 315 | _, err := s.client.Mutate().Create(&testDocumentWithCustomJSONMarshaler{}).Do(context.Background()) 316 | require.NoError(t, err) 317 | }) 318 | }) 319 | } 320 | 321 | func TestMutation_Builder_unmarshalResult(t *testing.T) { 322 | withSuite(t, func(s *Suite) { 323 | s.mux.Post("/v1/data/mutate/myDataset", func(w http.ResponseWriter, r *http.Request) { 324 | assert.Equal(t, "x", r.URL.Query().Get("transactionId")) 325 | w.WriteHeader(http.StatusOK) 326 | _, err := w.Write(mustJSONBytes(&api.MutateResponse{})) 327 | assert.NoError(t, err) 328 | }) 329 | 330 | _, err := s.client.Mutate().TransactionID("x").Do(context.Background()) 331 | require.NoError(t, err) 332 | }) 333 | } 334 | 335 | func TestMutation_Builder_transactionID(t *testing.T) { 336 | t.Run("can be set", func(t *testing.T) { 337 | withSuite(t, func(s *Suite) { 338 | s.mux.Post("/v1/data/mutate/myDataset", func(w http.ResponseWriter, r *http.Request) { 339 | assert.Equal(t, "x", r.URL.Query().Get("transactionId")) 340 | w.WriteHeader(http.StatusOK) 341 | _, err := w.Write(mustJSONBytes(&api.MutateResponse{})) 342 | assert.NoError(t, err) 343 | }) 344 | 345 | _, err := s.client.Mutate().TransactionID("x").Do(context.Background()) 346 | require.NoError(t, err) 347 | }) 348 | }) 349 | 350 | t.Run("not included by default", func(t *testing.T) { 351 | withSuite(t, func(s *Suite) { 352 | s.mux.Post("/v1/data/mutate/myDataset", func(w http.ResponseWriter, r *http.Request) { 353 | assert.Equal(t, "", r.URL.Query().Get("transactionId")) 354 | w.WriteHeader(http.StatusOK) 355 | _, err := w.Write(mustJSONBytes(&api.MutateResponse{})) 356 | assert.NoError(t, err) 357 | }) 358 | 359 | _, err := s.client.Mutate().Do(context.Background()) 360 | require.NoError(t, err) 361 | }) 362 | }) 363 | } 364 | 365 | func TestMutation_Builder_returnDocumentsOption(t *testing.T) { 366 | t.Run("can be set to false", func(t *testing.T) { 367 | withSuite(t, func(s *Suite) { 368 | s.mux.Post("/v1/data/mutate/myDataset", func(w http.ResponseWriter, r *http.Request) { 369 | assert.Equal(t, "false", r.URL.Query().Get("returnDocuments")) 370 | w.WriteHeader(http.StatusOK) 371 | _, err := w.Write(mustJSONBytes(&api.MutateResponse{})) 372 | assert.NoError(t, err) 373 | }) 374 | 375 | _, err := s.client.Mutate().ReturnDocuments(false).Do(context.Background()) 376 | require.NoError(t, err) 377 | }) 378 | }) 379 | 380 | t.Run("defaults to true", func(t *testing.T) { 381 | withSuite(t, func(s *Suite) { 382 | s.mux.Post("/v1/data/mutate/myDataset", func(w http.ResponseWriter, r *http.Request) { 383 | assert.Equal(t, "true", r.URL.Query().Get("returnDocuments")) 384 | w.WriteHeader(http.StatusOK) 385 | _, err := w.Write(mustJSONBytes(&api.MutateResponse{})) 386 | assert.NoError(t, err) 387 | }) 388 | 389 | _, err := s.client.Mutate().Do(context.Background()) 390 | require.NoError(t, err) 391 | }) 392 | }) 393 | } 394 | 395 | func TestMutation_Builder_visibilityOption(t *testing.T) { 396 | for _, v := range []api.MutationVisibility{ 397 | api.MutationVisibilityAsync, 398 | api.MutationVisibilityDeferred, 399 | api.MutationVisibilitySync, 400 | } { 401 | t.Run(fmt.Sprintf("can be set to %q", v), func(t *testing.T) { 402 | withSuite(t, func(s *Suite) { 403 | s.mux.Post("/v1/data/mutate/myDataset", func(w http.ResponseWriter, r *http.Request) { 404 | assert.Equal(t, string(v), r.URL.Query().Get("visibility")) 405 | w.WriteHeader(http.StatusOK) 406 | _, err := w.Write(mustJSONBytes(&api.MutateResponse{})) 407 | assert.NoError(t, err) 408 | }) 409 | 410 | _, err := s.client.Mutate().Visibility(v).Do(context.Background()) 411 | require.NoError(t, err) 412 | }) 413 | }) 414 | } 415 | 416 | t.Run("defaults to sync", func(t *testing.T) { 417 | withSuite(t, func(s *Suite) { 418 | s.mux.Post("/v1/data/mutate/myDataset", func(w http.ResponseWriter, r *http.Request) { 419 | assert.Equal(t, string(api.MutationVisibilitySync), r.URL.Query().Get("visibility")) 420 | w.WriteHeader(http.StatusOK) 421 | _, err := w.Write(mustJSONBytes(&api.MutateResponse{})) 422 | assert.NoError(t, err) 423 | }) 424 | 425 | _, err := s.client.Mutate().Do(context.Background()) 426 | require.NoError(t, err) 427 | }) 428 | }) 429 | } 430 | 431 | func TestMutation_Builder_dryRunOption(t *testing.T) { 432 | t.Run("can be set to true", func(t *testing.T) { 433 | withSuite(t, func(s *Suite) { 434 | s.mux.Post("/v1/data/mutate/myDataset", func(w http.ResponseWriter, r *http.Request) { 435 | assert.Equal(t, "true", r.URL.Query().Get("dryRun")) 436 | w.WriteHeader(http.StatusOK) 437 | _, err := w.Write(mustJSONBytes(&api.MutateResponse{})) 438 | assert.NoError(t, err) 439 | }) 440 | 441 | _, err := s.client.Mutate().DryRun(true).Do(context.Background()) 442 | require.NoError(t, err) 443 | }) 444 | }) 445 | 446 | t.Run("defaults to false", func(t *testing.T) { 447 | withSuite(t, func(s *Suite) { 448 | s.mux.Post("/v1/data/mutate/myDataset", func(w http.ResponseWriter, r *http.Request) { 449 | assert.Equal(t, "false", r.URL.Query().Get("dryRun")) 450 | w.WriteHeader(http.StatusOK) 451 | _, err := w.Write(mustJSONBytes(&api.MutateResponse{})) 452 | assert.NoError(t, err) 453 | }) 454 | 455 | _, err := s.client.Mutate().Do(context.Background()) 456 | require.NoError(t, err) 457 | }) 458 | }) 459 | } 460 | 461 | func TestMutation_Builder_tagOption(t *testing.T) { 462 | t.Run("can be overridden", func(t *testing.T) { 463 | withSuite(t, func(s *Suite) { 464 | s.mux.Post("/v1/data/mutate/myDataset", func(w http.ResponseWriter, r *http.Request) { 465 | assert.Equal(t, "foobar", r.URL.Query().Get("tag")) 466 | w.WriteHeader(http.StatusOK) 467 | _, err := w.Write(mustJSONBytes(&api.MutateResponse{})) 468 | assert.NoError(t, err) 469 | }) 470 | 471 | _, err := s.client.Mutate().Tag("foobar").Do(context.Background()) 472 | require.NoError(t, err) 473 | }, sanity.WithTag("default")) 474 | }) 475 | 476 | t.Run("honors default", func(t *testing.T) { 477 | withSuite(t, func(s *Suite) { 478 | s.mux.Post("/v1/data/mutate/myDataset", func(w http.ResponseWriter, r *http.Request) { 479 | assert.Equal(t, "default", r.URL.Query().Get("tag")) 480 | w.WriteHeader(http.StatusOK) 481 | _, err := w.Write(mustJSONBytes(&api.MutateResponse{})) 482 | assert.NoError(t, err) 483 | }) 484 | 485 | _, err := s.client.Mutate().Do(context.Background()) 486 | require.NoError(t, err) 487 | }, sanity.WithTag("default")) 488 | }) 489 | 490 | t.Run("defaults to empty", func(t *testing.T) { 491 | withSuite(t, func(s *Suite) { 492 | s.mux.Post("/v1/data/mutate/myDataset", func(w http.ResponseWriter, r *http.Request) { 493 | assert.Equal(t, "", r.URL.Query().Get("tag")) 494 | w.WriteHeader(http.StatusOK) 495 | _, err := w.Write(mustJSONBytes(&api.MutateResponse{})) 496 | assert.NoError(t, err) 497 | }) 498 | 499 | _, err := s.client.Mutate().Do(context.Background()) 500 | require.NoError(t, err) 501 | }) 502 | }) 503 | } 504 | -------------------------------------------------------------------------------- /package_test.go: -------------------------------------------------------------------------------- 1 | package sanity_test 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "net/http/httptest" 7 | "net/url" 8 | "testing" 9 | "time" 10 | 11 | "github.com/go-chi/chi" 12 | "github.com/stretchr/testify/require" 13 | 14 | sanity "github.com/sanity-io/client-go" 15 | ) 16 | 17 | type Suite struct { 18 | server *httptest.Server 19 | mux chi.Router 20 | client *sanity.Client 21 | } 22 | 23 | func withSuite(t *testing.T, f func(*Suite), opts ...sanity.Option) { 24 | t.Helper() 25 | 26 | mux := chi.NewRouter() 27 | 28 | suite := &Suite{ 29 | mux: mux, 30 | server: httptest.NewServer(mux), 31 | } 32 | 33 | url, err := url.Parse(suite.server.URL) 34 | require.NoError(t, err) 35 | url.Path = "/v1" 36 | 37 | opts = append(opts, sanity.WithHTTPHost(url.Scheme, url.Host)) 38 | 39 | c, err := sanity.VersionV1.NewClient("myProject", "myDataset", opts...) 40 | require.NoError(t, err) 41 | 42 | suite.client = c 43 | 44 | f(suite) 45 | } 46 | 47 | type testDocument struct { 48 | ID string `json:"_id"` 49 | Type string `json:"_type"` 50 | CreatedAt time.Time `json:"_createdAt"` 51 | UpdatedAt time.Time `json:"_updatedAt"` 52 | Value string `json:"value"` 53 | } 54 | 55 | func (d testDocument) toMap() map[string]interface{} { 56 | m, err := json.Marshal(d) 57 | if err != nil { 58 | panic(err) 59 | } 60 | 61 | var doc map[string]interface{} 62 | if err := json.Unmarshal(m, &doc); err != nil { 63 | panic(err) 64 | } 65 | return doc 66 | } 67 | 68 | type testDocumentWithCustomJSONMarshaler struct{} 69 | 70 | func (testDocumentWithCustomJSONMarshaler) MarshalJSON() ([]byte, error) { 71 | return []byte(`{"x":1}`), nil 72 | } 73 | 74 | type testDocumentWithJSONMarshalFailure struct{} 75 | 76 | func (testDocumentWithJSONMarshalFailure) MarshalJSON() ([]byte, error) { 77 | return nil, errMarshalFailure 78 | } 79 | 80 | type valueWithCustomJSONMarshaler float64 81 | 82 | func (*valueWithCustomJSONMarshaler) MarshalJSON() ([]byte, error) { 83 | return []byte("x"), nil 84 | } 85 | 86 | var errMarshalFailure = errors.New("failure") 87 | 88 | func mustJSONMsg(val interface{}) *json.RawMessage { 89 | b, err := json.Marshal(val) 90 | if err != nil { 91 | panic(err) 92 | } 93 | return (*json.RawMessage)(&b) 94 | } 95 | 96 | func mustJSONBytes(val interface{}) []byte { 97 | b, err := json.Marshal(val) 98 | if err != nil { 99 | panic(err) 100 | } 101 | return b 102 | } 103 | -------------------------------------------------------------------------------- /query.go: -------------------------------------------------------------------------------- 1 | package sanity 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "reflect" 9 | "time" 10 | 11 | "github.com/sanity-io/client-go/api" 12 | "github.com/sanity-io/client-go/internal/requests" 13 | ) 14 | 15 | // Query returns a new query builder. 16 | func (c *Client) Query(query string) *QueryBuilder { 17 | return &QueryBuilder{c: c, query: query} 18 | } 19 | 20 | // QueryResult holds the result of a query API call. 21 | type QueryResult struct { 22 | // Time is the time taken. 23 | Time time.Duration 24 | 25 | // Result is the raw JSON of the query result. 26 | Result *json.RawMessage 27 | } 28 | 29 | // Unmarshal unmarshals the result into a Go value or struct. If there were no results, the 30 | // destination value is set to the zero value. 31 | func (q *QueryResult) Unmarshal(dest interface{}) error { 32 | if q.Result == nil { 33 | v := reflect.ValueOf(&dest) 34 | if v.Kind() == reflect.Interface || v.Kind() == reflect.Ptr { 35 | i := reflect.Indirect(v) 36 | i.Set(reflect.Zero(i.Type())) 37 | } 38 | return nil 39 | } 40 | 41 | return json.Unmarshal([]byte(*q.Result), dest) 42 | } 43 | 44 | // QueryBuilder is a builder for queries. 45 | type QueryBuilder struct { 46 | c *Client 47 | query string 48 | params map[string]interface{} 49 | tag string 50 | } 51 | 52 | // Param adds a query parameter. For example, Param("foo", "bar") makes $foo usable inside the 53 | // query. The passed-in value must be serializable to a JSON primitive. 54 | func (qb *QueryBuilder) Param(name string, val interface{}) *QueryBuilder { 55 | if qb.params == nil { 56 | qb.params = make(map[string]interface{}, 10) // Small size 57 | } 58 | 59 | qb.params[name] = val 60 | return qb 61 | } 62 | 63 | func (qb *QueryBuilder) Tag(tag string) *QueryBuilder { 64 | qb.tag = tag 65 | return qb 66 | } 67 | 68 | // Query performs the query. On API failure, this will return an error of type *RequestError. 69 | func (qb *QueryBuilder) Do(ctx context.Context) (*QueryResult, error) { 70 | req, err := qb.buildGET() 71 | if err != nil { 72 | return nil, err 73 | } 74 | 75 | if len(req.EncodeURL()) > maxGETRequestURLLength { 76 | req, err = qb.buildPOST() 77 | if err != nil { 78 | return nil, err 79 | } 80 | } 81 | 82 | var resp api.QueryResponse 83 | if _, err := qb.c.do(ctx, req, &resp); err != nil { 84 | return nil, err 85 | } 86 | 87 | result := &QueryResult{ 88 | Time: time.Duration(resp.Ms) * time.Millisecond, 89 | Result: resp.Result, 90 | } 91 | 92 | if qb.c.callbacks.OnQueryResult != nil { 93 | qb.c.callbacks.OnQueryResult(result) 94 | } 95 | 96 | return result, nil 97 | } 98 | 99 | func (qb *QueryBuilder) buildGET() (*requests.Request, error) { 100 | req := qb.c.newQueryRequest(). 101 | AppendPath("data/query", qb.c.dataset). 102 | Param("query", qb.query). 103 | Tag(qb.tag, qb.c.tag) 104 | for p, v := range qb.params { 105 | b, err := json.Marshal(v) 106 | if err != nil { 107 | return nil, fmt.Errorf("marshaling parameter %q to JSON: %w", p, err) 108 | } 109 | req.Param("$"+p, string(b)) 110 | } 111 | return req, nil 112 | } 113 | 114 | func (qb *QueryBuilder) buildPOST() (*requests.Request, error) { 115 | request := &api.QueryRequest{ 116 | Query: qb.query, 117 | Params: make(map[string]*json.RawMessage, len(qb.params)), 118 | } 119 | 120 | for p, v := range qb.params { 121 | b, err := json.Marshal(v) 122 | if err != nil { 123 | return nil, fmt.Errorf("marshaling parameter %q to JSON: %w", p, err) 124 | } 125 | request.Params[p] = (*json.RawMessage)(&b) 126 | } 127 | 128 | return qb.c.newQueryRequest(). 129 | Method(http.MethodPost). 130 | AppendPath("data/query", qb.c.dataset). 131 | MarshalBody(request). 132 | Tag(qb.tag, qb.c.tag), nil 133 | } 134 | -------------------------------------------------------------------------------- /query_test.go: -------------------------------------------------------------------------------- 1 | package sanity_test 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "net/http" 7 | "strings" 8 | "testing" 9 | "time" 10 | 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | 14 | sanity "github.com/sanity-io/client-go" 15 | "github.com/sanity-io/client-go/api" 16 | ) 17 | 18 | func TestQuery_basic(t *testing.T) { 19 | groq := "*[0]" 20 | 21 | now := time.Date(2020, 1, 2, 23, 01, 44, 0, time.UTC) 22 | 23 | testDoc := &testDocument{ 24 | ID: "123", 25 | Type: "doc", 26 | CreatedAt: now, 27 | UpdatedAt: now, 28 | Value: "hello world", 29 | } 30 | 31 | withSuite(t, func(s *Suite) { 32 | s.mux.Get("/v1/data/query/myDataset", func(w http.ResponseWriter, r *http.Request) { 33 | assert.Equal(t, groq, r.URL.Query().Get("query")) 34 | for k := range r.URL.Query() { 35 | assert.False(t, strings.HasPrefix("$", k)) 36 | } 37 | 38 | w.WriteHeader(http.StatusOK) 39 | _, err := w.Write(mustJSONBytes(&api.QueryResponse{ 40 | Ms: 12, 41 | Result: mustJSONMsg(testDoc), 42 | })) 43 | assert.NoError(t, err) 44 | }) 45 | 46 | result, err := s.client.Query(groq).Do(context.Background()) 47 | require.NoError(t, err) 48 | 49 | assert.Equal(t, 12*time.Millisecond, result.Time) 50 | 51 | b, err := json.Marshal(testDoc) 52 | require.NoError(t, err) 53 | assert.Equal(t, string(b), string(*result.Result)) 54 | }) 55 | } 56 | 57 | func TestQuery_params(t *testing.T) { 58 | groq := "*[0]" 59 | 60 | for _, tc := range []struct { 61 | desc string 62 | val interface{} 63 | }{ 64 | {"integer", 1}, 65 | {"float", 1.23}, 66 | {"string", "hello"}, 67 | {"bool", true}, 68 | 69 | {"integer array", []int{1}}, 70 | {"float array", []float64{1.23}}, 71 | {"string array", []string{"hello", "world"}}, 72 | {"bool array", []bool{true, false}}, 73 | 74 | {"custom", valueWithCustomJSONMarshaler(123)}, 75 | } { 76 | t := t 77 | t.Run(tc.desc, func(t *testing.T) { 78 | withSuite(t, func(s *Suite) { 79 | s.mux.Get("/v1/data/query/myDataset", func(w http.ResponseWriter, r *http.Request) { 80 | assert.Equal(t, groq, r.URL.Query().Get("query")) 81 | 82 | b, err := json.Marshal(tc.val) 83 | require.NoError(t, err) 84 | assert.Equal(t, string(b), r.URL.Query().Get("$val")) 85 | 86 | w.WriteHeader(http.StatusOK) 87 | _, err = w.Write(mustJSONBytes(&api.QueryResponse{ 88 | Ms: 12, 89 | Result: mustJSONMsg(nil), 90 | })) 91 | assert.NoError(t, err) 92 | }) 93 | 94 | _, err := s.client.Query(groq).Param("val", tc.val).Do(context.Background()) 95 | require.NoError(t, err) 96 | }) 97 | }) 98 | } 99 | } 100 | 101 | func TestQuery_large(t *testing.T) { 102 | groq := "*[foo=='" + strings.Repeat("foo", 1000) + "']" 103 | 104 | withSuite(t, func(s *Suite) { 105 | s.mux.Post("/v1/data/query/myDataset", func(w http.ResponseWriter, r *http.Request) { 106 | var req api.QueryRequest 107 | require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) 108 | assert.Equal(t, groq, req.Query) 109 | assert.Equal(t, "1.23", string(*req.Params["val"])) 110 | assert.Empty(t, r.URL.Query()) 111 | 112 | w.WriteHeader(http.StatusOK) 113 | _, err := w.Write(mustJSONBytes(&api.QueryResponse{ 114 | Ms: 12, 115 | Result: mustJSONMsg(nil), 116 | })) 117 | assert.NoError(t, err) 118 | }) 119 | 120 | builder := s.client.Query(groq).Param("val", 1.23) 121 | 122 | _, err := builder.Do(context.Background()) 123 | require.NoError(t, err) 124 | }) 125 | } 126 | 127 | func TestQuery_tag(t *testing.T) { 128 | t.Run("small queries with default tag", func(t *testing.T) { 129 | groq := "*[foo=='" + strings.Repeat("foo", 1) + "']" 130 | 131 | withSuite(t, func(s *Suite) { 132 | s.mux.Get("/v1/data/query/myDataset", func(w http.ResponseWriter, r *http.Request) { 133 | assert.Equal(t, "default", r.URL.Query().Get("tag")) 134 | 135 | w.WriteHeader(http.StatusOK) 136 | _, err := w.Write(mustJSONBytes(&api.QueryResponse{})) 137 | assert.NoError(t, err) 138 | }) 139 | _, err := s.client.Query(groq).Param("val", 1.23).Do(context.Background()) 140 | require.NoError(t, err) 141 | }, sanity.WithTag("default")) 142 | }) 143 | t.Run("small queries overwrites tag", func(t *testing.T) { 144 | groq := "*[foo=='" + strings.Repeat("foo", 1) + "']" 145 | 146 | withSuite(t, func(s *Suite) { 147 | s.mux.Get("/v1/data/query/myDataset", func(w http.ResponseWriter, r *http.Request) { 148 | assert.Equal(t, "tag", r.URL.Query().Get("tag")) 149 | 150 | w.WriteHeader(http.StatusOK) 151 | _, err := w.Write(mustJSONBytes(&api.QueryResponse{})) 152 | assert.NoError(t, err) 153 | }) 154 | _, err := s.client.Query(groq).Param("val", 1.23).Tag("tag").Do(context.Background()) 155 | require.NoError(t, err) 156 | }, sanity.WithTag("default")) 157 | }) 158 | t.Run("large queries with default tag", func(t *testing.T) { 159 | groq := "*[foo=='" + strings.Repeat("foo", 1000) + "']" 160 | 161 | withSuite(t, func(s *Suite) { 162 | s.mux.Post("/v1/data/query/myDataset", func(w http.ResponseWriter, r *http.Request) { 163 | assert.Equal(t, "default", r.URL.Query().Get("tag")) 164 | 165 | w.WriteHeader(http.StatusOK) 166 | _, err := w.Write(mustJSONBytes(&api.QueryResponse{})) 167 | assert.NoError(t, err) 168 | }) 169 | _, err := s.client.Query(groq).Param("val", 1.23).Do(context.Background()) 170 | require.NoError(t, err) 171 | }, sanity.WithTag("default")) 172 | }) 173 | t.Run("large queries overwrites tag", func(t *testing.T) { 174 | groq := "*[foo=='" + strings.Repeat("foo", 1000) + "']" 175 | 176 | withSuite(t, func(s *Suite) { 177 | s.mux.Post("/v1/data/query/myDataset", func(w http.ResponseWriter, r *http.Request) { 178 | assert.Equal(t, "tag", r.URL.Query().Get("tag")) 179 | 180 | w.WriteHeader(http.StatusOK) 181 | _, err := w.Write(mustJSONBytes(&api.QueryResponse{})) 182 | assert.NoError(t, err) 183 | }) 184 | _, err := s.client.Query(groq).Param("val", 1.23).Tag("tag").Do(context.Background()) 185 | require.NoError(t, err) 186 | }, sanity.WithTag("default")) 187 | }) 188 | } 189 | -------------------------------------------------------------------------------- /utils.go: -------------------------------------------------------------------------------- 1 | package sanity 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | ) 8 | 9 | func isStatusCodeRetriable(code int) bool { 10 | switch code { 11 | case http.StatusServiceUnavailable, http.StatusGatewayTimeout, http.StatusRequestTimeout: 12 | return true 13 | default: 14 | return false 15 | } 16 | } 17 | 18 | func isMethodRetriable(method string) bool { 19 | switch method { 20 | case http.MethodGet, http.MethodHead, http.MethodDelete, http.MethodOptions: 21 | return true 22 | default: 23 | return false 24 | } 25 | } 26 | 27 | func marshalJSON(val interface{}) (*json.RawMessage, error) { 28 | switch val := val.(type) { 29 | case *json.RawMessage: 30 | return val, nil 31 | case []byte: 32 | return (*json.RawMessage)(&val), nil 33 | default: 34 | b, err := json.Marshal(val) 35 | if err != nil { 36 | return nil, fmt.Errorf("marshaling value of type %T to JSON: %w", val, err) 37 | } 38 | 39 | return (*json.RawMessage)(&b), nil 40 | } 41 | } 42 | --------------------------------------------------------------------------------