kinto/client.go (300 lines of code) (raw):
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package kinto // import "github.com/mozilla/OneCRL-Tools/kinto"
import (
"bytes"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"log"
"net/http"
"net/url"
"strconv"
"sync"
"time"
"github.com/mozilla/OneCRL-Tools/kinto/api"
"github.com/mozilla/OneCRL-Tools/kinto/api/auth"
"github.com/mozilla/OneCRL-Tools/kinto/api/authz"
"github.com/mozilla/OneCRL-Tools/kinto/api/batch"
"github.com/mozilla/OneCRL-Tools/kinto/api/buckets"
"github.com/mozilla/OneCRL-Tools/kinto/plugins/kintosigner"
)
type expectations map[int]bool
var (
ok = expectations{http.StatusOK: true}
okOrCreated = expectations{http.StatusOK: true, http.StatusCreated: true}
)
// Client is a thread safe client for the Kinto REST API.
//
// For information on the API that this client targets,
// please see the Kinto 1.x API documentation:
//
// https://docs.kinto-storage.org/en/stable/api/
type Client struct {
host string
base string
scheme string
tool string
backoff time.Duration
authenticator auth.Authenticator
inner *http.Client
lock sync.Mutex
}
// NewClient constructs a client with the scheme (E.G "https"),
// the host (E.G "firefox.settings.services.allizom.org"), and the API base (E.G "/v1").
func NewClient(scheme, host, base string) *Client {
return &Client{
host: host,
base: base,
scheme: scheme,
inner: new(http.Client),
authenticator: new(auth.Unauthenticated),
tool: "https://github.com/mozilla/OneCRL-Tools/kinto",
lock: sync.Mutex{},
}
}
func NewClientFromStr(u string) (*Client, error) {
addr, err := url.Parse(u)
if err != nil {
return nil, err
}
return NewClient(addr.Scheme, addr.Host, addr.Path), nil
}
// WithAuthenticator sets the authentication backend for future requests. The use of this
// configuration lazy and done on a per-request basis, so it is possible to use the same
// client but swap accounts in-and-out as necessary.
//
// Although this API is thread safe it is not advised to swap out authentication methods
// for client that is shared between goroutines as other threads may be assuming that a
// given authenticator is being used, which can lead to surprising results.
func (c *Client) WithAuthenticator(authenticator auth.Authenticator) *Client {
c.lock.Lock()
defer c.lock.Unlock()
c.authenticator = authenticator
return c
}
// WithToolHeader sets the header value for X-AUTOMATED-TOOL, which
// is sent with every request.
//
// By default, this is set to "https://github.com/mozilla/OneCRL-Tools/kinto",
// however it would be appreciated if consumers of this library set this to
// pointer to the code that is actually making API calls.
func (c *Client) WithToolHeader(tool string) *Client {
c.tool = tool
return c
}
// Alive returns back whether any error occurred while doing a GET on /
func (c *Client) Alive() bool {
req, err := c.newRequest(http.MethodGet, "/", nil)
if err != nil {
panic(err)
}
return c.do(req, nil, nil) == nil
}
// NewAdmin is the same as NewAccount, however with the "admin" user pre-configured.
func (c *Client) NewAdmin(password string) error {
return c.NewAccount(&auth.User{
Username: "admin",
Password: password,
})
}
// NewAccount creates a new Kinto local account using the provide principal and password.
//
// See https://docs.kinto-storage.org/en/stable/api/1.x/accounts.html#put--accounts-(user_id) for details.
func (c *Client) NewAccount(user *auth.User) error {
payload := api.NewPayload(user, nil)
req, err := c.newRequest(http.MethodPut, user.Put(), &payload)
if err != nil {
return err
}
return c.do(req, &payload, okOrCreated)
}
// NewBucket creates a new bucket with default permissions.
//
// See https://docs.kinto-storage.org/en/stable/api/1.x/buckets.html#post--buckets for details.
func (c *Client) NewBucket(bucket *buckets.Bucket) error {
return c.NewBucketWithPermissions(bucket, nil)
}
// NewBucketWithPermissions creates a new bucket with the provided permissions.
//
// See https://docs.kinto-storage.org/en/stable/api/1.x/buckets.html#post--buckets for details.
func (c *Client) NewBucketWithPermissions(bucket *buckets.Bucket, perms *authz.Permissions) error {
payload := api.NewPayload(bucket, perms)
req, err := c.newRequest(http.MethodPost, bucket.Post(), &payload)
if err != nil {
return err
}
return c.do(req, &payload, okOrCreated)
}
// NewCollection creates a new collection with the provided permissions.
//
// For details, please see:
// https://docs.kinto-storage.org/en/stable/api/1.x/collections.html#post--buckets-(bucket_id)-collections
func (c *Client) NewCollection(collection api.Poster) error {
return c.NewCollectionWithPermissions(collection, nil)
}
// NewCollectionWithPermissions creates a new collection with the provided permissions.
//
// For details, please see:
// https://docs.kinto-storage.org/en/stable/api/1.x/collections.html#post--buckets-(bucket_id)-collections
func (c *Client) NewCollectionWithPermissions(collection api.Poster, perms *authz.Permissions) error {
payload := api.NewPayload(collection, perms)
req, err := c.newRequest(http.MethodPost, collection.Post(), &payload)
if err != nil {
return err
}
return c.do(req, &payload, okOrCreated)
}
// Batch POSTs a single batch request. Note that the size of a batch request is bounded
// by the remote server's "batch_max_requests" settings (which can be found under "settings" under the root resource).
//
// The most reliable way to to use this endpoint is to query this limit via `BatchMaxRequests` and use that value
// in the batch.NewBatches API.
//
// For details, please see:
// https://docs.kinto-storage.org/en/stable/api/1.x/batch.html
func (c *Client) Batch(b *batch.Batch) error {
req, err := c.newRequest(http.MethodPost, b.Post(), &b)
if err != nil {
return err
}
return c.do(req, nil, okOrCreated)
}
// AllRecords retrieves all records for the given collection.
//
// For details, please see:
// https://docs.kinto-storage.org/en/stable/api/1.x/records.html#retrieving-stored-records
func (c *Client) AllRecords(collection api.Getter) error {
r, err := c.newRequest(http.MethodGet, collection.Get(), nil)
if err != nil {
return err
}
return c.do(r, collection, ok)
}
// NewRecord POSTs a new record under the given collection with default permissions.
//
// For details, please see:
// https://docs.kinto-storage.org/en/stable/api/1.x/records.html#uploading-a-record
func (c *Client) NewRecord(collection api.Getter, record interface{}) error {
return c.NewRecordWithPermissions(collection, record, nil)
}
// NewRecordWithPermissions POSTs a new record under the given collection with the given permissions.
//
// For details, please see:
// https://docs.kinto-storage.org/en/stable/api/1.x/records.html#uploading-a-record
func (c *Client) NewRecordWithPermissions(collection api.Getter, record interface{}, perms *authz.Permissions) error {
payload := api.NewPayload(record, perms)
req, err := c.newRequest(http.MethodPost, collection.Get(), &payload)
if err != nil {
return err
}
return c.do(req, &payload, okOrCreated)
}
// UpdateRecord PATCHes a given record under the given collection with default permissions.
//
// For details, please see:
// https://docs.kinto-storage.org/en/stable/api/1.x/records.html#patch--buckets-(bucket_id)-collections-(collection_id)-records-(record_id)
func (c *Client) UpdateRecord(collection api.Getter, record api.Recorded) error {
return c.UpdateRecordWithPermissions(collection, record, nil)
}
// UpdateRecordWithPermissions PATCHes a given record under the given collection with the given permissions.
//
// For details, please see:
// https://docs.kinto-storage.org/en/stable/api/1.x/records.html#patch--buckets-(bucket_id)-collections-(collection_id)-records-(record_id)
func (c *Client) UpdateRecordWithPermissions(collection api.Getter, record api.Recorded, perms *authz.Permissions) error {
payload := api.NewPayload(record.(interface{}), perms)
req, err := c.newRequest(http.MethodPatch, collection.Get()+"/"+record.ID(), &payload)
if err != nil {
return err
}
return c.do(req, &payload, okOrCreated)
}
// Delete deletes the given record from the given collection.
//
// For details, please see:
// https://docs.kinto-storage.org/en/stable/api/1.x/records.html#delete-stored-records
func (c *Client) Delete(collection api.Getter, record api.Recorded) (*api.DeleteResponse, error) {
resp := new(api.DeleteResponse)
req, err := c.newRequest(http.MethodDelete, collection.Get()+"/"+record.ID(), nil)
if err != nil {
return resp, err
}
return resp, c.do(req, resp, ok)
}
// SignerStatusFor retrieves the Kinto Signer signer status for the given collection.
//
// For details on the Kinto Signer plugin, please see:
// https://github.com/Kinto/kinto-signer
func (c *Client) SignerStatusFor(collection api.Patcher) (*kintosigner.Status, error) {
resp := new(kintosigner.Status)
req, err := c.newRequest(http.MethodGet, collection.Patch(), nil)
if err != nil {
return resp, err
}
return resp, c.do(req, resp, ok)
}
// ToReview puts the given collection into the "to-review" state.
//
// For details on the Kinto Signer plugin, please see:
// https://github.com/Kinto/kinto-signer
func (c *Client) ToReview(collection api.Patcher) error {
req, err := c.newRequest(http.MethodPatch, collection.Patch(), kintosigner.ToReview())
if err != nil {
return err
}
return c.do(req, nil, okOrCreated)
}
// ToWIP puts the given collection into the "work-in-progress" state.
//
// For details on the Kinto Signer plugin, please see:
// https://github.com/Kinto/kinto-signer
func (c *Client) ToWIP(collection api.Patcher) error {
req, err := c.newRequest(http.MethodPatch, collection.Patch(), kintosigner.WIP())
if err != nil {
return err
}
return c.do(req, nil, okOrCreated)
}
// ToSign puts the given collection into the "to-sign" state.
//
// For details on the Kinto Signer plugin, please see:
// https://github.com/Kinto/kinto-signer
func (c *Client) ToSign(collection api.Patcher) error {
req, err := c.newRequest(http.MethodPatch, collection.Patch(), kintosigner.ToSign())
if err != nil {
return err
}
return c.do(req, nil, okOrCreated)
}
// ToSigned puts the given collection into the "signed" state.
//
// For details on the Kinto Signer plugin, please see:
// https://github.com/Kinto/kinto-signer
func (c *Client) ToSigned(collection api.Patcher) error {
req, err := c.newRequest(http.MethodPatch, collection.Patch(), kintosigner.Signed())
if err != nil {
return err
}
return c.do(req, nil, okOrCreated)
}
// ToRollBack puts the given collection into the "to-rollback" state.
//
// For details on the Kinto Signer plugin, please see:
// https://github.com/Kinto/kinto-signer
func (c *Client) ToRollBack(collection api.Patcher) error {
req, err := c.newRequest(http.MethodPatch, collection.Patch(), kintosigner.ToRollback())
if err != nil {
return err
}
return c.do(req, nil, okOrCreated)
}
// ToResign puts the given collection into the "to-resign" state.
//
// For details on the Kinto Signer plugin, please see:
// https://github.com/Kinto/kinto-signer
func (c *Client) ToResign(collection api.Patcher) error {
req, err := c.newRequest(http.MethodPatch, collection.Patch(), kintosigner.ToResign())
if err != nil {
return err
}
return c.do(req, nil, okOrCreated)
}
// TryAuth does a GET on the Kinto's root resource and checks for the presence of user
// metadata in order to determine the configured authenticator successfully authenticates.
//
// See https://docs.kinto-storage.org/en/stable/api/1.x/authentication.html#try-authentication for details.
func (c *Client) TryAuth() (bool, error) {
r, err := c.newRequest(http.MethodGet, "/", nil)
if err != nil {
return false, err
}
ret := make(map[string]interface{})
err = c.do(r, &ret, map[int]bool{200: true})
if err != nil {
return false, err
}
_, authenticated := ret["user"]
return authenticated, nil
}
// BatchMaxRequests retrieves the "settings.batch_max_requests" from the utility endpoint.
//
// This API is most useful when used in conjunction withe batch.NewBatches API.
//
// For details, please see:
// https://docs.kinto-storage.org/en/stable/api/1.x/utilities.html#api-utilities
func (c *Client) BatchMaxRequests() (int, error) {
answer := struct {
Settings struct {
BatchMaxRequests int `json:"batch_max_requests"`
} `json:"settings"`
}{}
r, err := c.newRequest(http.MethodGet, "/", nil)
if err != nil {
return 0, err
}
err = c.do(r, &answer, map[int]bool{200: true})
if err != nil {
return 0, err
}
return answer.Settings.BatchMaxRequests, nil
}
func (c *Client) newRequest(method string, endpoint string, body interface{}) (*http.Request, error) {
var b io.Reader
if body != nil {
bodyBytes, err := json.Marshal(body)
if err != nil {
return nil, err
}
b = bytes.NewReader(bodyBytes)
}
req, err := http.NewRequest(method, fmt.Sprintf("%s://%s%s%s", c.scheme, c.host, c.base, endpoint), b)
if err != nil {
return nil, err
}
req.Header.Set("X-AUTOMATED-TOOL", c.tool)
return req, nil
}
func (c *Client) do(r *http.Request, target interface{}, accept expectations) error {
backoff := c.getBackoff()
c.authenticate(r)
if backoff > 0 {
// Kinto kindly asks us that we backoff when necessary
// See https://docs.kinto-storage.org/en/stable/api/1.x/backoff.html
log.Printf("Kinto has asked us to backoff for %d seconds\n", c.backoff)
time.Sleep(time.Second * c.backoff)
}
resp, err := c.inner.Do(r)
if err != nil {
return err
}
receivedBackoff := resp.Header.Get("Backoff")
if receivedBackoff != "" {
b, err := strconv.Atoi(receivedBackoff)
if err != nil {
return fmt.Errorf(
"Kinto gave us a Backoff header, but "+
"it did not parse to an integer. Got '%s'",
receivedBackoff)
}
c.setBackoff(time.Second * time.Duration(b))
} else {
c.setBackoff(time.Duration(0))
}
if accept != nil {
if _, ok := accept[resp.StatusCode]; !ok {
defer resp.Body.Close()
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("expected status code %v, got %d", accept, resp.StatusCode)
}
return fmt.Errorf("expected status code %v, got %d. Message %s", accept, resp.StatusCode, string(b))
}
}
if target != nil {
defer resp.Body.Close()
return json.NewDecoder(resp.Body).Decode(&target)
}
return nil
}
func (c *Client) authenticate(r *http.Request) {
c.lock.Lock()
defer c.lock.Unlock()
c.authenticator.Authenticate(r)
}
func (c *Client) getBackoff() time.Duration {
c.lock.Lock()
defer c.lock.Unlock()
return c.backoff
}
func (c *Client) setBackoff(backoff time.Duration) {
c.lock.Lock()
defer c.lock.Unlock()
c.backoff = backoff
}