diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..f5bdb14d74 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +vendor +.netrc + +# Project binaries. +/idp +/transpilerd diff --git a/Gopkg.lock b/Gopkg.lock new file mode 100644 index 0000000000..e4f654138b --- /dev/null +++ b/Gopkg.lock @@ -0,0 +1,163 @@ +# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. + + +[[projects]] + branch = "master" + name = "github.com/beorn7/perks" + packages = ["quantile"] + revision = "3a771d992973f24aa725d07868b467d1ddfceafb" + +[[projects]] + name = "github.com/gogo/protobuf" + packages = ["proto"] + revision = "1adfc126b41513cc696b209667c8656ea7aac67c" + version = "v1.0.0" + +[[projects]] + name = "github.com/golang/protobuf" + packages = ["proto"] + revision = "b4deda0973fb4c70b50d226b1af49f3da59f5265" + version = "v1.1.0" + +[[projects]] + name = "github.com/google/go-cmp" + packages = [ + "cmp", + "cmp/internal/diff", + "cmp/internal/function", + "cmp/internal/value" + ] + revision = "3af367b6b30c263d47e8895973edcca9a49cf029" + version = "v0.2.0" + +[[projects]] + branch = "master" + name = "github.com/influxdata/ifql" + packages = [ + "ast", + "compiler", + "id", + "interpreter", + "parser", + "query", + "query/execute", + "query/plan", + "semantic", + "values" + ] + revision = "66973752cd65cd99dc43332a94272e250672d448" + +[[projects]] + branch = "master" + name = "github.com/influxdata/influxdb" + packages = [ + "models", + "pkg/escape", + "pkg/snowflake" + ] + revision = "200fda999f915dfc13c2b4030a2db6e0b08c689f" + +[[projects]] + name = "github.com/julienschmidt/httprouter" + packages = ["."] + revision = "d1898390779332322e6b5ca5011da4bf249bb056" + +[[projects]] + name = "github.com/matttproud/golang_protobuf_extensions" + packages = ["pbutil"] + revision = "3247c84500bff8d9fb6d579d800f20b3e091582c" + version = "v1.0.0" + +[[projects]] + name = "github.com/opentracing/opentracing-go" + packages = [ + ".", + "log" + ] + revision = "1949ddbfd147afd4d964a9f00b24eb291e0e7c38" + version = "v1.0.2" + +[[projects]] + name = "github.com/pkg/errors" + packages = ["."] + revision = "645ef00459ed84a119197bfb8d8205042c6df63d" + version = "v0.8.0" + +[[projects]] + name = "github.com/prometheus/client_golang" + packages = [ + "prometheus", + "prometheus/promhttp" + ] + revision = "661e31bf844dfca9aeba15f27ea8aa0d485ad212" + +[[projects]] + branch = "master" + name = "github.com/prometheus/client_model" + packages = ["go"] + revision = "99fa1f4be8e564e8a6b613da7fa6f46c9edafc6c" + +[[projects]] + branch = "master" + name = "github.com/prometheus/common" + packages = [ + "expfmt", + "internal/bitbucket.org/ww/goautoneg", + "model" + ] + revision = "d811d2e9bf898806ecfb6ef6296774b13ffc314c" + +[[projects]] + branch = "master" + name = "github.com/prometheus/procfs" + packages = [ + ".", + "internal/util", + "nfs", + "xfs" + ] + revision = "8b1c2da0d56deffdbb9e48d4414b4e674bd8083e" + +[[projects]] + name = "github.com/satori/go.uuid" + packages = ["."] + revision = "f58768cc1a7a7e77a3bd49e98cdd21419399b6a3" + version = "v1.2.0" + +[[projects]] + name = "go.uber.org/atomic" + packages = ["."] + revision = "4e336646b2ef9fc6e47be8e21594178f98e5ebcf" + version = "v1.2.0" + +[[projects]] + name = "go.uber.org/multierr" + packages = ["."] + revision = "3c4937480c32f4c13a875a1829af76c98ca3d40a" + version = "v1.1.0" + +[[projects]] + name = "go.uber.org/zap" + packages = [ + ".", + "buffer", + "internal/bufferpool", + "internal/color", + "internal/exit", + "zapcore" + ] + revision = "eeedf312bc6c57391d84767a4cd413f02a917974" + version = "v1.8.0" + +[[projects]] + branch = "master" + name = "golang.org/x/net" + packages = ["context"] + revision = "2491c5de3490fced2f6cff376127c667efeed857" + +[solve-meta] + analyzer-name = "dep" + analyzer-version = 1 + inputs-digest = "9a6351899a346f8d38fe7215b78df522799b8a8aefb6619c9025a1ace2549126" + solver-name = "gps-cdcl" + solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml new file mode 100644 index 0000000000..4015a35fb7 --- /dev/null +++ b/Gopkg.toml @@ -0,0 +1,62 @@ +# Gopkg.toml example +# +# Refer to https://golang.github.io/dep/docs/Gopkg.toml.html +# for detailed Gopkg.toml documentation. +# +# required = ["github.com/user/thing/cmd/thing"] +# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] +# +# [[constraint]] +# name = "github.com/user/project" +# version = "1.0.0" +# +# [[constraint]] +# name = "github.com/user/project2" +# branch = "dev" +# source = "github.com/myfork/project2" +# +# [[override]] +# name = "github.com/x/y" +# version = "2.4.0" +# +# [prune] +# non-go = false +# go-tests = true +# unused-packages = true + + +[[constraint]] + name = "github.com/gogo/protobuf" + version = "1.0.0" + +[[constraint]] + name = "github.com/google/go-cmp" + version = "0.2.0" + +[[constraint]] + name = "github.com/influxdata/ifql" + branch = "master" + +[[constraint]] + name = "github.com/influxdata/influxdb" + branch = "master" + +# Dependency is pinned to explicit revision rather than latest release because +# latest release pre-dates context, which we need. +[[constraint]] + name = "github.com/julienschmidt/httprouter" + revision = "d1898390779332322e6b5ca5011da4bf249bb056" + +# Prometheus are currently pre v1.0, so the API is changing considerably. +# The latest release (0.8) predates exported API requirements. +[[constraint]] + name = "github.com/prometheus/client_golang" + revision = "661e31bf844dfca9aeba15f27ea8aa0d485ad212" + +[[constraint]] + name = "go.uber.org/zap" + version = "1.8.0" + +[prune] + go-tests = true + unused-packages = true diff --git a/README.md b/README.md index 9b82d3afc7..de19c0818c 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,3 @@ This project is using [`dep`](https://golang.github.io/dep/docs/introduction.htm Use `dep ensure -vendor-only` when you only need to populate the `vendor` directory to run `go build` successfully, or run `dep ensure` to both populate the `vendor` directory and update `Gopkg.lock` with any newer constraints. - -For more detail about our team's `dep` workflows, refer to our -[draft dep guide](https://docs.google.com/document/d/1a7VsgPBFmeRxVLOKz_NrGRlBI88tcWniZPPMJTu5ez0/edit) -(which [should eventually be merged into the `docs` folder](https://github.com/influxdata/idpe/issues/467)). diff --git a/auth.go b/auth.go new file mode 100644 index 0000000000..0540719ed4 --- /dev/null +++ b/auth.go @@ -0,0 +1,151 @@ +package platform + +import ( + "context" + "errors" + "fmt" + "regexp" +) + +// Authorization is a authorization. 🎉 +type Authorization struct { + Token string `json:"token"` + UserID ID `json:"userID,omitempty"` + Permissions []Permission `json:"permissions"` +} + +// AuthorizationService represents a service for managing authorization data. +type AuthorizationService interface { + // Returns a single authorization by Token. + FindAuthorizationByToken(ctx context.Context, t string) (*Authorization, error) + + // Returns a list of authorizations that match filter and the total count of matching authorizations. + // Additional options provide pagination & sorting. + FindAuthorizations(ctx context.Context, filter AuthorizationFilter, opt ...FindOptions) ([]*Authorization, int, error) + + // Creates a new authorization and sets a.Token with the new identifier. + CreateAuthorization(ctx context.Context, a *Authorization) error + + // Removes a authorization by token. + DeleteAuthorization(ctx context.Context, t string) error +} + +// AuthorizationFilter represents a set of filter that restrict the returned results. +type AuthorizationFilter struct { + Token *string + UserID *ID +} + +type action string + +const ( + // ReadAction is the action for reading. + ReadAction action = "read" + // WriteAction is the action for writing. + WriteAction action = "write" + // CreateAction is the action for creating new resources. + CreateAction action = "create" + // DeleteAction is the action for deleting an existing resource. + DeleteAction action = "delete" +) + +type resource string + +const ( + // UserResource represents the user resource actions can apply to. + UserResource = resource("user") + // OrganizationResource represents the org resource actions can apply to. + OrganizationResource = resource("org") +) + +// BucketResource constructs a bucket resource. +func BucketResource(t string, b string) resource { + return resource(fmt.Sprintf("org:%s:bucket:%s", t, b)) +} + +// Permission defines an action and a resource. +type Permission struct { + Action action `json:"action"` + Resource resource `json:"resource"` +} + +func (p Permission) String() string { + return fmt.Sprintf("%s:%s", p.Action, p.Resource) +} + +// ConstructPermission constructs a permission from a provided action and resource. +func ConstructPermission(a string, r string) (Permission, error) { + constructedAction := action(a) + constructedResource := resource(r) + if !validAction(constructedAction) { + return Permission{}, errors.New("invalid permission action") + } else if !validResource(constructedResource) { + return Permission{}, errors.New("invalid permission resource") + } + p := Permission{ + Action: constructedAction, + Resource: constructedResource, + } + + return p, nil +} + +func validAction(a action) bool { + validActions := [4]action{ReadAction, WriteAction, CreateAction, DeleteAction} + for _, x := range validActions { + if a == x { + return true + } + } + return false +} + +func validResource(r resource) bool { + validResources := [2]resource{UserResource, OrganizationResource} + bucketRegex, _ := regexp.Compile(`org:.+:bucket:.+`) + for _, x := range validResources { + if r == x { + return true + } + } + return bucketRegex.MatchString(string(r)) +} + +var ( + // CreateUser is a permission for creating users. + CreateUser = Permission{ + Action: CreateAction, + Resource: UserResource, + } + // DeleteUser is a permission for deleting users. + DeleteUser = Permission{ + Action: DeleteAction, + Resource: UserResource, + } +) + +// ReadBucket constructs a permission for reading a bucket. +func ReadBucket(o, b string) Permission { + return Permission{ + Action: ReadAction, + Resource: BucketResource(o, b), + } +} + +// WriteBucket constructs a permission for writing to a bucket. +func WriteBucket(o, b string) Permission { + return Permission{ + Action: WriteAction, + Resource: BucketResource(o, b), + } +} + +// Allowed returns true if the permission exists in a list of permissions. +func Allowed(req Permission, ps []Permission) bool { + for _, p := range ps { + if p.Action == req.Action && p.Resource == req.Resource { + return true + } + } + return false +} diff --git a/bucket.go b/bucket.go new file mode 100644 index 0000000000..f6e7386a66 --- /dev/null +++ b/bucket.go @@ -0,0 +1,59 @@ +package platform + +import ( + "context" + "time" +) + +// Bucket is a bucket. 🎉 +type Bucket struct { + ID ID `json:"id"` + OrganizationID ID `json:"organizationID"` + Name string `json:"name"` + RetentionPeriod time.Duration `json:"retentionPeriod"` +} + +// BucketService represents a service for managing bucket data. +type BucketService interface { + // FindBucketByID returns a single bucket by ID. + FindBucketByID(ctx context.Context, id ID) (*Bucket, error) + + // FindBucket returns the first bucket that matches filter. + FindBucket(ctx context.Context, filter BucketFilter) (*Bucket, error) + + // FindBuckets returns a list of buckets that match filter and the total count of matching buckets. + // Additional options provide pagination & sorting. + FindBuckets(ctx context.Context, filter BucketFilter, opt ...FindOptions) ([]*Bucket, int, error) + + // CreateBucket creates a new bucket and sets b.ID with the new identifier. + CreateBucket(ctx context.Context, b *Bucket) error + + // UpdateBucket updates a single bucket with changeset. + // Returns the new bucket state after update. + UpdateBucket(ctx context.Context, id ID, upd BucketUpdate) (*Bucket, error) + + // DeleteBucket removes a bucket by ID. + DeleteBucket(ctx context.Context, id ID) error +} + +// BucketUpdate represents updates to a bucket. +// Only fields which are set are updated. +type BucketUpdate struct { + Name *string `json:"name,omitempty"` + RetentionPeriod *time.Duration `json:"retentionPeriod,omitempty"` +} + +// BucketFilter represents a set of filter that restrict the returned results. +type BucketFilter struct { + ID *ID + OrganizationID *ID + Name *string +} + +// FindOptions represents options passed to all find methods with multiple results. +type FindOptions struct { + Limit int + Offset int + SortBy string + Descending bool +} diff --git a/context/token.go b/context/token.go new file mode 100644 index 0000000000..756632a05d --- /dev/null +++ b/context/token.go @@ -0,0 +1,52 @@ +package context + +import ( + "context" + "net/http" + + "github.com/influxdata/platform" + "github.com/influxdata/platform/kit/errors" +) + +type contextKey string + +const ( + authorizationCtxKey = contextKey("influx/authorization/v1") + tokenCtxKey = contextKey("influx/token/v1") +) + +// SetAuthorization sets an authorization on context. +func SetAuthorization(ctx context.Context, a *platform.Authorization) context.Context { + return context.WithValue(ctx, authorizationCtxKey, a) +} + +// GetAuthorization retrieves an authorization from context. +func GetAuthorization(ctx context.Context) (*platform.Authorization, error) { + a, ok := ctx.Value(authorizationCtxKey).(*platform.Authorization) + if !ok { + return nil, errors.InternalErrorf("authorization not found on context") + } + + return a, nil +} + +// SetToken sets an authorization on context. +func SetToken(ctx context.Context, t string) context.Context { + return context.WithValue(ctx, tokenCtxKey, t) +} + +// GeToken retrieves an authorization from context. +func GetToken(ctx context.Context) (string, error) { + t, ok := ctx.Value(tokenCtxKey).(string) + if !ok { + return "", errors.InternalErrorf("token not found on context") + } + + return t, nil +} + +// TokenFromAuthorizationHeader retrieves a auth token from the HTTP Authorization header. +var TokenFromAuthorizationHeader = func(ctx context.Context, r *http.Request) context.Context { + token := r.Header.Get("Authorization") + return SetToken(ctx, token) +} diff --git a/http/README.md b/http/README.md new file mode 100644 index 0000000000..6a9f5604db --- /dev/null +++ b/http/README.md @@ -0,0 +1,89 @@ +# HTTP Handler Style Guide + +### HTTP Handler +* Each handler should implement `http.Handler` + - This can be done by embedding a [`httprouter.Router`](https://github.com/julienschmidt/httprouter) + (a light weight HTTP router that supports variables in the routing pattern and matches against the request method) +* Required services should be exported on the struct + +```go +// ThingHandler represents an HTTP API handler for things. +type ThingHandler struct { + // embedded httprouter.Router as a lazy way to implement http.Handler + *httprouter.Router + + ThingService platform.ThingService + AuthorizationService platform.AuthorizationService + + Logger *zap.Logger +} +``` + +### HTTP Handler Constructor + +* Routes should be declared in the constructor + +```go +// NewThingHandler returns a new instance of ThingHandler. +func NewThingHandler() *ThingHandler { + h := &ThingHandler{ + Router: httprouter.New(), + Logger: zap.Nop(), + } + + h.HandlerFunc("POST", "/v1/things", h.handlePostThing) + h.HandlerFunc("GET", "/v1/things", h.handleGetThings) + + return h +} +``` + +### Route handlers (`http.HandlerFunc`s) + +* Each route handler should have an associated request struct and decode function +* The decode function should take a `context.Context` and an `*http.Request` and return the associated route request struct + +```go +type postThingRequest struct { + Thing *platform.Thing +} + +func decodePostThingRequest(ctx context.Context, r *http.Request) (*postThingRequest, error) { + t := &platform.Thing{} + if err := json.NewDecoder(r.Body).Decode(t); err != nil { + return nil, err + } + + return &postThingRequest{ + Thing: t, + }, nil +} +``` + +* Route `http.HandlerFuncs` should separate the decoding and encoding of HTTP requests/response from actual handler logic + +```go +// handlePostThing is the HTTP handler for the POST /v1/things route. +func (h *ThingHandler) handlePostThing(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + req, err := decodePostThingRequest(ctx, r) + if err != nil { + errors.EncodeHTTP(ctx, err, w) + return + } + + // Do stuff here + if err := h.ThingService.CreateThing(ctx, req.Thing); err != nil { + errors.EncodeHTTP(ctx, err, w) + return + } + + if err := encodeResponse(ctx, w, http.StatusCreated, req.Thing); err != nil { + h.Logger.Info("encoding response failed", zap.Error(err)) + return + } +} +``` + +* `http.HandlerFunc`'s that require particular encoding of http responses should implement an encode response function diff --git a/http/auth_service.go b/http/auth_service.go new file mode 100644 index 0000000000..e2a24e982c --- /dev/null +++ b/http/auth_service.go @@ -0,0 +1,313 @@ +package http + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + + "go.uber.org/zap" + + "github.com/influxdata/platform" + "github.com/influxdata/platform/kit/errors" + "github.com/julienschmidt/httprouter" +) + +// AuthorizationHandler represents an HTTP API handler for authorizations. +type AuthorizationHandler struct { + *httprouter.Router + Logger *zap.Logger + + AuthorizationService platform.AuthorizationService +} + +// NewAuthorizationHandler returns a new instance of AuthorizationHandler. +func NewAuthorizationHandler() *AuthorizationHandler { + h := &AuthorizationHandler{ + Router: httprouter.New(), + } + + h.HandlerFunc("POST", "/v1/authorizations", h.handlePostAuthorization) + h.HandlerFunc("GET", "/v1/authorizations", h.handleGetAuthorizations) + + // TODO(desa): Remove this when we get the all clear + h.HandlerFunc("POST", "/influx/v1/authorizations", h.handlePostAuthorization) + h.HandlerFunc("GET", "/influx/v1/authorizations", h.handleGetAuthorizations) + return h +} + +// handlePostAuthorization is the HTTP handler for the POST /v1/authorizations route. +func (h *AuthorizationHandler) handlePostAuthorization(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + req, err := decodePostAuthorizationRequest(ctx, r) + if err != nil { + h.Logger.Info("failed to decode request", zap.String("handler", "postAuthorization"), zap.Error(err)) + errors.EncodeHTTP(ctx, err, w) + return + } + + // TODO: Need to do some validation of req.Authorization.Permissions + + if err := h.AuthorizationService.CreateAuthorization(ctx, req.Authorization); err != nil { + // Don't log here, it should already be handled by the service + errors.EncodeHTTP(ctx, err, w) + return + } + + if err := encodeResponse(ctx, w, http.StatusCreated, req.Authorization); err != nil { + h.Logger.Info("failed to encode response", zap.String("handler", "postAuthorization"), zap.Error(err)) + errors.EncodeHTTP(ctx, err, w) + return + } +} + +type postAuthorizationRequest struct { + Authorization *platform.Authorization +} + +func decodePostAuthorizationRequest(ctx context.Context, r *http.Request) (*postAuthorizationRequest, error) { + a := &platform.Authorization{} + if err := json.NewDecoder(r.Body).Decode(a); err != nil { + return nil, err + } + + return &postAuthorizationRequest{ + Authorization: a, + }, nil +} + +// handleGetAuthorizations is the HTTP handler for the GET /v1/authorizations route. +func (h *AuthorizationHandler) handleGetAuthorizations(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + req, err := decodeGetAuthorizationsRequest(ctx, r) + if err != nil { + h.Logger.Info("failed to decode request", zap.String("handler", "getAuthorizations"), zap.Error(err)) + errors.EncodeHTTP(ctx, err, w) + return + } + + as, _, err := h.AuthorizationService.FindAuthorizations(ctx, req.filter) + if err != nil { + // Don't log here, it should already be handled by the service + errors.EncodeHTTP(ctx, err, w) + return + } + + if err := encodeResponse(ctx, w, http.StatusOK, as); err != nil { + h.Logger.Info("failed to encode response", zap.String("handler", "getAuthorizations"), zap.Error(err)) + errors.EncodeHTTP(ctx, err, w) + return + } +} + +type getAuthorizationsRequest struct { + filter platform.AuthorizationFilter +} + +func decodeGetAuthorizationsRequest(ctx context.Context, r *http.Request) (*getAuthorizationsRequest, error) { + qp := r.URL.Query() + userID := qp.Get("userID") + + req := &getAuthorizationsRequest{} + + if userID != "" { + var id platform.ID + if err := (&id).Decode([]byte(userID)); err != nil { + return nil, err + } + + req.filter.UserID = &id + } + + return req, nil +} + +// AuthorizationService connects to Influx via HTTP using tokens to manage authorizations +type AuthorizationService struct { + Addr string + Token string + InsecureSkipVerify bool +} + +var _ platform.AuthorizationService = (*AuthorizationService)(nil) + +// FindAuthorizationByToken returns a single authorization by Token. +func (s *AuthorizationService) FindAuthorizationByToken(ctx context.Context, token string) (*platform.Authorization, error) { + u, err := newURL(s.Addr, authorizationTokenPath(token)) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", u.String(), nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", s.Token) + + hc := newClient(u.Scheme, s.InsecureSkipVerify) + resp, err := hc.Do(req) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + dec := json.NewDecoder(resp.Body) + if resp.StatusCode != http.StatusNoContent { + var reqErr errors.Error + if err := dec.Decode(&reqErr); err != nil { + return nil, err + } + return nil, reqErr + } + + var b platform.Authorization + if err := dec.Decode(&b); err != nil { + return nil, err + } + + return &b, nil +} + +// FindAuthorization returns the first authorization that matches filter. +func (s *AuthorizationService) FindAuthorization(ctx context.Context, filter platform.AuthorizationFilter) (*platform.Authorization, error) { + authorizations, n, err := s.FindAuthorizations(ctx, filter) + if err != nil { + return nil, err + } + + if n == 0 { + return nil, fmt.Errorf("found no matching authorization") + } + + return authorizations[0], nil +} + +// FindAuthorizations returns a list of authorizations that match filter and the total count of matching authorizations. +// Additional options provide pagination & sorting. +func (s *AuthorizationService) FindAuthorizations(ctx context.Context, filter platform.AuthorizationFilter, opt ...platform.FindOptions) ([]*platform.Authorization, int, error) { + u, err := newURL(s.Addr, authorizationPath) + if err != nil { + return nil, 0, err + } + + query := u.Query() + + req, err := http.NewRequest("GET", u.String(), nil) + if err != nil { + return nil, 0, err + } + + req.URL.RawQuery = query.Encode() + req.Header.Set("Authorization", s.Token) + + hc := newClient(u.Scheme, s.InsecureSkipVerify) + resp, err := hc.Do(req) + if err != nil { + return nil, 0, err + } + + defer resp.Body.Close() + dec := json.NewDecoder(resp.Body) + if resp.StatusCode != http.StatusOK { + var reqErr errors.Error + if err := dec.Decode(&reqErr); err != nil { + return nil, 0, err + } + return nil, 0, reqErr + } + + var bs []*platform.Authorization + if err := dec.Decode(&bs); err != nil { + return nil, 0, err + } + + return bs, len(bs), nil +} + +const ( + authorizationPath = "/v1/authorizations" +) + +// CreateAuthorization creates a new authorization and sets b.ID with the new identifier. +func (s *AuthorizationService) CreateAuthorization(ctx context.Context, b *platform.Authorization) error { + u, err := newURL(s.Addr, authorizationPath) + if err != nil { + return err + } + + octets, err := json.Marshal(b) + if err != nil { + return err + } + + req, err := http.NewRequest("POST", u.String(), bytes.NewReader(octets)) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", s.Token) + + hc := newClient(u.Scheme, s.InsecureSkipVerify) + + resp, err := hc.Do(req) + if err != nil { + return err + } + + // TODO: this should really check the error from the headers + if resp.StatusCode != http.StatusCreated { + defer resp.Body.Close() + dec := json.NewDecoder(resp.Body) + var reqErr errors.Error + if err := dec.Decode(&reqErr); err != nil { + return err + } + return reqErr + } + + if err := json.NewDecoder(resp.Body).Decode(b); err != nil { + return err + } + + return nil +} + +// DeleteAuthorization removes a authorization by token. +func (s *AuthorizationService) DeleteAuthorization(ctx context.Context, token string) error { + u, err := newURL(s.Addr, authorizationTokenPath(token)) + if err != nil { + return err + } + + req, err := http.NewRequest("DELETE", u.String(), nil) + if err != nil { + return err + } + req.Header.Set("Authorization", s.Token) + + hc := newClient(u.Scheme, s.InsecureSkipVerify) + resp, err := hc.Do(req) + if err != nil { + return err + } + + if resp.StatusCode != http.StatusNoContent { + defer resp.Body.Close() + dec := json.NewDecoder(resp.Body) + var reqErr errors.Error + if err := dec.Decode(&reqErr); err != nil { + return err + } + return reqErr + } + + return nil +} + +func authorizationTokenPath(token string) string { + return authorizationPath + "/" + token +} diff --git a/http/bucket_service.go b/http/bucket_service.go new file mode 100644 index 0000000000..3785f6322c --- /dev/null +++ b/http/bucket_service.go @@ -0,0 +1,455 @@ +package http + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/influxdata/platform" + "github.com/influxdata/platform/kit/errors" + "github.com/julienschmidt/httprouter" +) + +// BucketHandler represents an HTTP API handler for buckets. +type BucketHandler struct { + *httprouter.Router + + BucketService platform.BucketService +} + +// NewBucketHandler returns a new instance of BucketHandler. +func NewBucketHandler() *BucketHandler { + h := &BucketHandler{ + Router: httprouter.New(), + } + + h.HandlerFunc("POST", "/v1/buckets", h.handlePostBucket) + h.HandlerFunc("GET", "/v1/buckets", h.handleGetBuckets) + h.HandlerFunc("GET", "/v1/buckets/:id", h.handleGetBucket) + h.HandlerFunc("PATCH", "/v1/buckets/:id", h.handlePatchBucket) + return h +} + +// handlePostBucket is the HTTP handler for the POST /v1/buckets route. +func (h *BucketHandler) handlePostBucket(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + req, err := decodePostBucketRequest(ctx, r) + if err != nil { + errors.EncodeHTTP(ctx, err, w) + return + } + + if err := h.BucketService.CreateBucket(ctx, req.Bucket); err != nil { + errors.EncodeHTTP(ctx, err, w) + return + } + + if err := encodeResponse(ctx, w, http.StatusCreated, req.Bucket); err != nil { + errors.EncodeHTTP(ctx, err, w) + return + } +} + +type postBucketRequest struct { + Bucket *platform.Bucket +} + +func decodePostBucketRequest(ctx context.Context, r *http.Request) (*postBucketRequest, error) { + b := &platform.Bucket{} + if err := json.NewDecoder(r.Body).Decode(b); err != nil { + return nil, err + } + + return &postBucketRequest{ + Bucket: b, + }, nil +} + +// handleGetBucket is the HTTP handler for the GET /v1/buckets/:id route. +func (h *BucketHandler) handleGetBucket(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + req, err := decodeGetBucketRequest(ctx, r) + if err != nil { + errors.EncodeHTTP(ctx, err, w) + return + } + + b, err := h.BucketService.FindBucketByID(ctx, req.BucketID) + if err != nil { + errors.EncodeHTTP(ctx, err, w) + return + } + + if err := encodeResponse(ctx, w, http.StatusOK, b); err != nil { + errors.EncodeHTTP(ctx, err, w) + return + } +} + +type getBucketRequest struct { + BucketID platform.ID +} + +func decodeGetBucketRequest(ctx context.Context, r *http.Request) (*getBucketRequest, error) { + params := httprouter.ParamsFromContext(ctx) + id := params.ByName("id") + if id == "" { + return nil, errors.InvalidDataf("url missing id") + } + + var i platform.ID + if err := (&i).Decode([]byte(id)); err != nil { + return nil, err + } + req := &getBucketRequest{ + BucketID: i, + } + + return req, nil +} + +// handleGetBuckets is the HTTP handler for the GET /v1/buckets route. +func (h *BucketHandler) handleGetBuckets(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + req, err := decodeGetBucketsRequest(ctx, r) + if err != nil { + errors.EncodeHTTP(ctx, err, w) + return + } + + bs, _, err := h.BucketService.FindBuckets(ctx, req.filter) + if err != nil { + errors.EncodeHTTP(ctx, err, w) + return + } + + if err := encodeResponse(ctx, w, http.StatusOK, bs); err != nil { + errors.EncodeHTTP(ctx, err, w) + return + } +} + +type getBucketsRequest struct { + filter platform.BucketFilter +} + +func decodeGetBucketsRequest(ctx context.Context, r *http.Request) (*getBucketsRequest, error) { + qp := r.URL.Query() + req := &getBucketsRequest{} + + if id := qp.Get("orgID"); id != "" { + req.filter.OrganizationID = &platform.ID{} + if err := req.filter.OrganizationID.DecodeFromString(id); err != nil { + return nil, err + } + } + + if id := qp.Get("bucketID"); id != "" { + req.filter.ID = &platform.ID{} + if err := req.filter.ID.DecodeFromString(id); err != nil { + return nil, err + } + } + + if name := qp.Get("bucketName"); name != "" { + req.filter.Name = &name + } + + return req, nil +} + +// handlePatchBucket is the HTTP handler for the PATH /v1/buckets route. +func (h *BucketHandler) handlePatchBucket(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + req, err := decodePatchBucketRequest(ctx, r) + if err != nil { + errors.EncodeHTTP(ctx, err, w) + return + } + + b, err := h.BucketService.UpdateBucket(ctx, req.BucketID, req.Update) + if err != nil { + errors.EncodeHTTP(ctx, err, w) + return + } + + if err := encodeResponse(ctx, w, http.StatusOK, b); err != nil { + errors.EncodeHTTP(ctx, err, w) + return + } +} + +type patchBucketRequest struct { + Update platform.BucketUpdate + BucketID platform.ID +} + +func decodePatchBucketRequest(ctx context.Context, r *http.Request) (*patchBucketRequest, error) { + params := httprouter.ParamsFromContext(ctx) + id := params.ByName("id") + if id == "" { + return nil, errors.InvalidDataf("url missing id") + } + + var i platform.ID + if err := (&i).Decode([]byte(id)); err != nil { + return nil, err + } + + var upd platform.BucketUpdate + if err := json.NewDecoder(r.Body).Decode(&upd); err != nil { + return nil, err + } + + return &patchBucketRequest{ + Update: upd, + BucketID: i, + }, nil +} + +const ( + bucketPath = "/v1/buckets" +) + +// BucketService connects to Influx via HTTP using tokens to manage buckets +type BucketService struct { + Addr string + Token string + InsecureSkipVerify bool +} + +// FindBucketByID returns a single bucket by ID. +func (s *BucketService) FindBucketByID(ctx context.Context, id platform.ID) (*platform.Bucket, error) { + u, err := newURL(s.Addr, bucketplatformath(id)) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", u.String(), nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", s.Token) + + hc := newClient(u.Scheme, s.InsecureSkipVerify) + resp, err := hc.Do(req) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + dec := json.NewDecoder(resp.Body) + if resp.StatusCode != http.StatusNoContent { + var reqErr errors.Error + if err := dec.Decode(&reqErr); err != nil { + return nil, err + } + return nil, reqErr + } + + var b platform.Bucket + if err := dec.Decode(&b); err != nil { + return nil, err + } + + return &b, nil +} + +// FindBucket returns the first bucket that matches filter. +func (s *BucketService) FindBucket(ctx context.Context, filter platform.BucketFilter) (*platform.Bucket, error) { + bs, n, err := s.FindBuckets(ctx, filter) + if err != nil { + return nil, err + } + + if n == 0 { + return nil, fmt.Errorf("found no matching buckets") + } + + return bs[0], nil +} + +// FindBuckets returns a list of buckets that match filter and the total count of matching buckets. +// Additional options provide pagination & sorting. +func (s *BucketService) FindBuckets(ctx context.Context, filter platform.BucketFilter, opt ...platform.FindOptions) ([]*platform.Bucket, int, error) { + u, err := newURL(s.Addr, bucketPath) + if err != nil { + return nil, 0, err + } + + query := u.Query() + if filter.OrganizationID != nil { + query.Add("orgID", filter.OrganizationID.String()) + } + if filter.ID != nil { + query.Add("bucketID", filter.ID.String()) + } + if filter.Name != nil { + query.Add("bucketName", *filter.Name) + } + + req, err := http.NewRequest("GET", u.String(), nil) + if err != nil { + return nil, 0, err + } + + req.URL.RawQuery = query.Encode() + req.Header.Set("Authorization", s.Token) + + hc := newClient(u.Scheme, s.InsecureSkipVerify) + resp, err := hc.Do(req) + if err != nil { + return nil, 0, err + } + + defer resp.Body.Close() + dec := json.NewDecoder(resp.Body) + if resp.StatusCode != http.StatusOK { + var reqErr errors.Error + if err := dec.Decode(&reqErr); err != nil { + return nil, 0, err + } + return nil, 0, reqErr + } + + var bs []*platform.Bucket + if err := dec.Decode(&bs); err != nil { + return nil, 0, err + } + + return bs, len(bs), nil +} + +// CreateBucket creates a new bucket and sets b.ID with the new identifier. +func (s *BucketService) CreateBucket(ctx context.Context, b *platform.Bucket) error { + u, err := newURL(s.Addr, bucketPath) + if err != nil { + return err + } + + octets, err := json.Marshal(b) + if err != nil { + return err + } + + req, err := http.NewRequest("POST", u.String(), bytes.NewReader(octets)) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", s.Token) + + hc := newClient(u.Scheme, s.InsecureSkipVerify) + + resp, err := hc.Do(req) + if err != nil { + return err + } + + // TODO: this should really check the error from the headers + if resp.StatusCode != http.StatusCreated { + defer resp.Body.Close() + dec := json.NewDecoder(resp.Body) + var reqErr errors.Error + if err := dec.Decode(&reqErr); err != nil { + return err + } + return reqErr + } + + if err := json.NewDecoder(resp.Body).Decode(b); err != nil { + return err + } + + return nil +} + +// UpdateBucket updates a single bucket with changeset. +// Returns the new bucket state after update. +func (s *BucketService) UpdateBucket(ctx context.Context, id platform.ID, upd platform.BucketUpdate) (*platform.Bucket, error) { + u, err := newURL(s.Addr, bucketplatformath(id)) + if err != nil { + return nil, err + } + + octets, err := json.Marshal(upd) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("PATCH", u.String(), bytes.NewReader(octets)) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", s.Token) + + hc := newClient(u.Scheme, s.InsecureSkipVerify) + + resp, err := hc.Do(req) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + dec := json.NewDecoder(resp.Body) + + if resp.StatusCode != http.StatusCreated { + var reqErr errors.Error + if err := dec.Decode(&reqErr); err != nil { + return nil, err + } + return nil, reqErr + } + + var b platform.Bucket + if err := dec.Decode(&b); err != nil { + return nil, err + } + + return &b, nil +} + +// DeleteBucket removes a bucket by ID. +func (s *BucketService) DeleteBucket(ctx context.Context, id platform.ID) error { + u, err := newURL(s.Addr, bucketplatformath(id)) + if err != nil { + return err + } + + req, err := http.NewRequest("DELETE", u.String(), nil) + if err != nil { + return err + } + req.Header.Set("Authorization", s.Token) + + hc := newClient(u.Scheme, s.InsecureSkipVerify) + resp, err := hc.Do(req) + if err != nil { + return err + } + + if resp.StatusCode != http.StatusNoContent { + defer resp.Body.Close() + dec := json.NewDecoder(resp.Body) + var reqErr errors.Error + if err := dec.Decode(&reqErr); err != nil { + return err + } + return reqErr + } + + return nil +} + +func bucketplatformath(id platform.ID) string { + return bucketPath + "/" + id.String() +} diff --git a/http/client.go b/http/client.go new file mode 100644 index 0000000000..9e6b2d0792 --- /dev/null +++ b/http/client.go @@ -0,0 +1,35 @@ +package http + +import ( + "crypto/tls" + "net/http" + "net/url" +) + +// Shared transports for all clients to prevent leaking connections +var ( + skipVerifyTransport = &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + defaultTransport = &http.Transport{} +) + +func newURL(addr, path string) (*url.URL, error) { + u, err := url.Parse(addr) + if err != nil { + return nil, err + } + u.Path = path + return u, nil +} + +func newClient(scheme string, insecure bool) *http.Client { + hc := &http.Client{ + Transport: defaultTransport, + } + if scheme == "https" && insecure { + hc.Transport = skipVerifyTransport + } + + return hc +} diff --git a/http/handler.go b/http/handler.go new file mode 100644 index 0000000000..3d3bb61d88 --- /dev/null +++ b/http/handler.go @@ -0,0 +1,116 @@ +package http + +import ( + "context" + "encoding/json" + "net/http" + _ "net/http/pprof" + "strings" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +const ( + MetricsPath = "/metrics" + HealthzPath = "/healthz" + DebugPath = "/debug" +) + +// Handler provides basic handling of metrics, healthz and debug endpoints. +// All other requests are passed down to the sub handler. +type Handler struct { + name string + // HealthzHandler handles healthz requests + HealthzHandler http.Handler + // MetricsHandler handles metrics requests + MetricsHandler http.Handler + // DebugHandler handles debug requests + DebugHandler http.Handler + // Handler handles all other requests + Handler http.Handler +} + +// NewHandler creates a new handler with the given name. +// The name is used to tag the metrics produced by this handler. +func NewHandler(name string) *Handler { + return &Handler{ + name: name, + MetricsHandler: promhttp.Handler(), + DebugHandler: http.DefaultServeMux, + } +} + +// ServeHTTP delegates a request to the appropriate subhandler. +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // TODO: better way to do this? + statusW := newStatusResponseWriter(w) + w = statusW + + // TODO: This could be problematic eventually. But for now it should be fine. + defer func() { + httpRequests.With(prometheus.Labels{ + "handler": h.name, + "method": r.Method, + "path": r.URL.Path, + "status": statusW.statusCodeClass(), + }).Inc() + }() + defer func(start time.Time) { + httpRequestDuration.With(prometheus.Labels{ + "handler": h.name, + "method": r.Method, + "path": r.URL.Path, + "status": statusW.statusCodeClass(), + }).Observe(time.Since(start).Seconds()) + }(time.Now()) + + switch { + case r.URL.Path == MetricsPath: + h.MetricsHandler.ServeHTTP(w, r) + case r.URL.Path == HealthzPath: + h.HealthzHandler.ServeHTTP(w, r) + case strings.HasPrefix(r.URL.Path, DebugPath): + h.DebugHandler.ServeHTTP(w, r) + default: + h.Handler.ServeHTTP(w, r) + } +} + +func encodeResponse(ctx context.Context, w http.ResponseWriter, code int, res interface{}) error { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(code) + + return json.NewEncoder(w).Encode(res) +} + +func init() { + prometheus.MustRegister(httpCollectors...) +} + +// namespace is the leading part of all published metrics for the http handler service. +const namespace = "http" + +const handlerSubsystem = "api" + +// http metrics track request latency and count by path. +var ( + httpRequests = prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: namespace, + Subsystem: handlerSubsystem, + Name: "requests_total", + Help: "Number of http requests received", + }, []string{"handler", "method", "path", "status"}) + + httpRequestDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: namespace, + Subsystem: handlerSubsystem, + Name: "request_duration_seconds", + Help: "Time taken to respond to HTTP request", + // TODO(desa): determine what spacing these buckets should have. + Buckets: prometheus.ExponentialBuckets(0.001, 1.5, 25), + }, []string{"handler", "method", "path", "status"}) + + httpCollectors = []prometheus.Collector{httpRequests, httpRequestDuration} +) diff --git a/http/org_service.go b/http/org_service.go new file mode 100644 index 0000000000..f120737325 --- /dev/null +++ b/http/org_service.go @@ -0,0 +1,339 @@ +package http + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/influxdata/platform" + "github.com/influxdata/platform/kit/errors" + "github.com/julienschmidt/httprouter" +) + +// OrgHandler represents an HTTP API handler for orgs. +type OrgHandler struct { + *httprouter.Router + + OrganizationService platform.OrganizationService +} + +// NewOrgHandler returns a new instance of OrgHandler. +func NewOrgHandler() *OrgHandler { + h := &OrgHandler{ + Router: httprouter.New(), + } + + h.HandlerFunc("POST", "/v1/orgs", h.handlePostOrg) + h.HandlerFunc("GET", "/v1/orgs", h.handleGetOrgs) + h.HandlerFunc("GET", "/v1/orgs/:id", h.handleGetOrg) + h.HandlerFunc("PATCH", "/v1/orgs/:id", h.handlePatchOrg) + return h +} + +// handlePostOrg is the HTTP handler for the POST /v1/orgs route. +func (h *OrgHandler) handlePostOrg(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + req, err := decodePostOrgRequest(ctx, r) + if err != nil { + errors.EncodeHTTP(ctx, err, w) + return + } + + if err := h.OrganizationService.CreateOrganization(ctx, req.Org); err != nil { + errors.EncodeHTTP(ctx, err, w) + return + } + + if err := encodeResponse(ctx, w, http.StatusCreated, req.Org); err != nil { + errors.EncodeHTTP(ctx, err, w) + return + } +} + +type postOrgRequest struct { + Org *platform.Organization +} + +func decodePostOrgRequest(ctx context.Context, r *http.Request) (*postOrgRequest, error) { + o := &platform.Organization{} + if err := json.NewDecoder(r.Body).Decode(o); err != nil { + return nil, err + } + + return &postOrgRequest{ + Org: o, + }, nil +} + +// handleGetOrg is the HTTP handler for the GET /v1/orgs/:id route. +func (h *OrgHandler) handleGetOrg(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + req, err := decodeGetOrgRequest(ctx, r) + if err != nil { + errors.EncodeHTTP(ctx, err, w) + return + } + + b, err := h.OrganizationService.FindOrganizationByID(ctx, req.OrgID) + if err != nil { + errors.EncodeHTTP(ctx, err, w) + return + } + + if err := encodeResponse(ctx, w, http.StatusOK, b); err != nil { + errors.EncodeHTTP(ctx, err, w) + return + } +} + +type getOrgRequest struct { + OrgID platform.ID +} + +func decodeGetOrgRequest(ctx context.Context, r *http.Request) (*getOrgRequest, error) { + params := httprouter.ParamsFromContext(ctx) + id := params.ByName("id") + if id == "" { + return nil, errors.InvalidDataf("url missing id") + } + + var i platform.ID + if err := (&i).Decode([]byte(id)); err != nil { + return nil, err + } + + req := &getOrgRequest{ + OrgID: i, + } + + return req, nil +} + +// handleGetOrgs is the HTTP handler for the GET /v1/orgs route. +func (h *OrgHandler) handleGetOrgs(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + req, err := decodeGetOrgsRequest(ctx, r) + if err != nil { + errors.EncodeHTTP(ctx, err, w) + return + } + + orgs, _, err := h.OrganizationService.FindOrganizations(ctx, req.filter) + if err != nil { + errors.EncodeHTTP(ctx, err, w) + return + } + + if err := encodeResponse(ctx, w, http.StatusOK, orgs); err != nil { + errors.EncodeHTTP(ctx, err, w) + return + } +} + +type getOrgsRequest struct { + filter platform.OrganizationFilter +} + +func decodeGetOrgsRequest(ctx context.Context, r *http.Request) (*getOrgsRequest, error) { + qp := r.URL.Query() + req := &getOrgsRequest{} + + if id := qp.Get("orgID"); id != "" { + req.filter.ID = &platform.ID{} + if err := req.filter.ID.DecodeFromString(id); err != nil { + return nil, err + } + } + + if name := qp.Get("orgName"); name != "" { + req.filter.Name = &name + } + + return req, nil +} + +// handlePatchOrg is the HTTP handler for the PATH /v1/orgs route. +func (h *OrgHandler) handlePatchOrg(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + req, err := decodePatchOrgRequest(ctx, r) + if err != nil { + errors.EncodeHTTP(ctx, err, w) + return + } + + o, err := h.OrganizationService.UpdateOrganization(ctx, req.OrgID, req.Update) + if err != nil { + errors.EncodeHTTP(ctx, err, w) + return + } + + if err := encodeResponse(ctx, w, http.StatusOK, o); err != nil { + errors.EncodeHTTP(ctx, err, w) + return + } +} + +type patchOrgRequest struct { + Update platform.OrganizationUpdate + OrgID platform.ID +} + +func decodePatchOrgRequest(ctx context.Context, r *http.Request) (*patchOrgRequest, error) { + params := httprouter.ParamsFromContext(ctx) + id := params.ByName("id") + if id == "" { + return nil, errors.InvalidDataf("url missing id") + } + + var i platform.ID + if err := (&i).Decode([]byte(id)); err != nil { + return nil, err + } + + var upd platform.OrganizationUpdate + if err := json.NewDecoder(r.Body).Decode(&upd); err != nil { + return nil, err + } + + return &patchOrgRequest{ + Update: upd, + OrgID: i, + }, nil +} + +const ( + organizationPath = "/v1/orgs" +) + +// OrganizationService connects to Influx via HTTP using tokens to manage organizations. +type OrganizationService struct { + Addr string + Token string + InsecureSkipVerify bool +} + +func (s *OrganizationService) FindOrganizationByID(ctx context.Context, id platform.ID) (*platform.Organization, error) { + filter := platform.OrganizationFilter{ID: &id} + return s.FindOrganization(ctx, filter) +} + +func (s *OrganizationService) FindOrganization(ctx context.Context, filter platform.OrganizationFilter) (*platform.Organization, error) { + os, n, err := s.FindOrganizations(ctx, filter) + if err != nil { + return nil, err + } + + if n < 1 { + return nil, fmt.Errorf("expected at least one organization") + } + + return os[0], nil +} + +func (s *OrganizationService) FindOrganizations(ctx context.Context, filter platform.OrganizationFilter, opt ...platform.FindOptions) ([]*platform.Organization, int, error) { + url, err := newURL(s.Addr, organizationPath) + if err != nil { + return nil, 0, err + } + qp := url.Query() + + if filter.Name != nil { + qp.Add("orgName", *filter.Name) + } + if filter.ID != nil { + qp.Add("orgID", filter.ID.String()) + } + url.RawQuery = qp.Encode() + + req, err := http.NewRequest("GET", url.String(), nil) + if err != nil { + return nil, 0, err + } + + req.Header.Set("Authorization", s.Token) + hc := newClient(url.Scheme, s.InsecureSkipVerify) + + resp, err := hc.Do(req) + if err != nil { + return nil, 0, err + } + + // TODO: this should really check the error from the headers + if resp.StatusCode != http.StatusOK { + defer resp.Body.Close() + dec := json.NewDecoder(resp.Body) + var reqErr errors.Error + if err := dec.Decode(&reqErr); err != nil { + return nil, 0, err + } + return nil, 0, reqErr + } + + var os []*platform.Organization + + if err := json.NewDecoder(resp.Body).Decode(&os); err != nil { + return nil, 0, err + } + + return os, len(os), nil + +} + +// CreateOrganization creates an organization. +func (s *OrganizationService) CreateOrganization(ctx context.Context, o *platform.Organization) error { + url, err := newURL(s.Addr, organizationPath) + if err != nil { + return err + } + + octets, err := json.Marshal(o) + if err != nil { + return err + } + + req, err := http.NewRequest("POST", url.String(), bytes.NewReader(octets)) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", s.Token) + + hc := newClient(url.Scheme, s.InsecureSkipVerify) + + resp, err := hc.Do(req) + if err != nil { + return err + } + + // TODO: this should really check the error from the headers + if resp.StatusCode != http.StatusCreated { + defer resp.Body.Close() + dec := json.NewDecoder(resp.Body) + var reqErr errors.Error + if err := dec.Decode(&reqErr); err != nil { + return err + } + return reqErr + } + + if err := json.NewDecoder(resp.Body).Decode(o); err != nil { + return err + } + + return nil +} + +func (s *OrganizationService) UpdateOrganization(ctx context.Context, id platform.ID, upd platform.OrganizationUpdate) (*platform.Organization, error) { + panic("not implemented") +} + +func (s *OrganizationService) DeleteOrganization(ctx context.Context, id platform.ID) error { + panic("not implemented") +} diff --git a/http/status_response_writer.go b/http/status_response_writer.go new file mode 100644 index 0000000000..5fbbf8a729 --- /dev/null +++ b/http/status_response_writer.go @@ -0,0 +1,37 @@ +package http + +import "net/http" + +type statusResponseWriter struct { + statusCode int + http.ResponseWriter +} + +func newStatusResponseWriter(w http.ResponseWriter) *statusResponseWriter { + return &statusResponseWriter{ + ResponseWriter: w, + } +} + +// WriteHeader writes +func (w *statusResponseWriter) WriteHeader(statusCode int) { + w.statusCode = statusCode + w.ResponseWriter.WriteHeader(statusCode) +} + +func (w *statusResponseWriter) statusCodeClass() string { + class := "XXX" + switch w.statusCode / 100 { + case 1: + class = "1XX" + case 2: + class = "2XX" + case 3: + class = "3XX" + case 4: + class = "4XX" + case 5: + class = "5XX" + } + return class +} diff --git a/http/swagger.yml b/http/swagger.yml new file mode 100644 index 0000000000..df2b132b4e --- /dev/null +++ b/http/swagger.yml @@ -0,0 +1,348 @@ +openapi: "3.0.0" +info: + title: Gateway Service + version: 0.1.0 +servers: + - url: /v1 +paths: + /buckets: + get: + tags: + - Buckets + summary: List all buckets + responses: + '200': + description: a list of buckets + content: + application/json: + schema: + $ref: "#/components/schemas/Buckets" + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + post: + tags: + - Buckets + summary: Create a bucket + requestBody: + description: bucket to create + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/Bucket" + responses: + '201': + description: Bucket created + content: + application/json: + schema: + $ref: "#/components/schemas/Bucket" + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + '/buckets/{bucketId}': + get: + tags: + - Buckets + summary: Retrieve a bucket + parameters: + - in: path + name: bucketId + schema: + type: string + required: true + description: ID of bucket to get + responses: + '200': + description: bucket details + content: + application/json: + schema: + $ref: "#/components/schemas/Bucket" + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + patch: + tags: + - Buckets + summary: Update a bucket + requestBody: + description: bucket update to apply + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/Bucket" + parameters: + - in: path + name: bucketId + schema: + type: string + required: true + description: ID of bucket to update + responses: + '200': + description: An updated bucket + content: + application/json: + schema: + $ref: "#/components/schemas/Bucket" + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /orgs: + get: + tags: + - Organizations + summary: List all organizations + responses: + '200': + description: A list of organizations + content: + application/json: + schema: + $ref: "#/components/schemas/Organizations" + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + post: + tags: + - Organizations + summary: Create an organization + requestBody: + description: organization to create + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/Organization" + responses: + '201': + description: organization created + content: + application/json: + schema: + $ref: "#/components/schemas/Organization" + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + '/orgs/{orgId}': + get: + tags: + - Organizations + summary: Retrieve an organization + parameters: + - in: path + name: orgId + schema: + type: string + required: true + description: ID of organization to get + responses: + '200': + description: organization details + content: + application/json: + schema: + $ref: "#/components/schemas/Organization" + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + patch: + tags: + - Organizations + summary: Update an organization + requestBody: + description: organization update to apply + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/Organization" + parameters: + - in: path + name: orgId + schema: + type: string + required: true + description: ID of organization to get + responses: + '200': + description: organization updated + content: + application/json: + schema: + $ref: "#/components/schemas/Organization" + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /users: + get: + tags: + - Users + summary: List all users + responses: + '200': + description: a list of users + content: + application/json: + schema: + $ref: "#/components/schemas/Users" + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + post: + tags: + - Users + summary: Create a user + requestBody: + description: user to create + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/User" + responses: + '201': + description: user created + content: + application/json: + schema: + $ref: "#/components/schemas/User" + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + '/users/{userId}': + get: + tags: + - Users + summary: Retrieve a user + parameters: + - in: path + name: userId + schema: + type: string + required: true + description: ID of user to get + responses: + '200': + description: user details + content: + application/json: + schema: + $ref: "#/components/schemas/User" + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + patch: + tags: + - Users + summary: Update a user + requestBody: + description: user update to apply + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/User" + parameters: + - in: path + name: userId + schema: + type: string + required: true + description: ID of user to update + responses: + '200': + description: user updated + content: + application/json: + schema: + $ref: "#/components/schemas/User" + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" +components: + schemas: + Bucket: + properties: + id: + type: string + organizationId: + type: string + name: + type: string + retentionPeriod: + type: integer + format: int64 + Buckets: + type: array + items: + $ref: "#/components/schemas/Bucket" + Organization: + properties: + id: + type: string + name: + type: string + Organizations: + type: array + items: + $ref: "#/components/schemas/Organization" + User: + properties: + id: + type: string + name: + type: string + Users: + type: array + items: + $ref: "#/components/schemas/User" + Error: + required: + - code + - message + properties: + code: + type: integer + format: int32 + message: + type: string diff --git a/http/usage_service.go b/http/usage_service.go new file mode 100644 index 0000000000..944b8808e9 --- /dev/null +++ b/http/usage_service.go @@ -0,0 +1,126 @@ +package http + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/influxdata/platform" + "github.com/influxdata/platform/kit/errors" + "github.com/julienschmidt/httprouter" +) + +// UsageHandler represents an HTTP API handler for usages. +type UsageHandler struct { + *httprouter.Router + + UsageService platform.UsageService +} + +// NewUsageHandler returns a new instance of UsageHandler. +func NewUsageHandler() *UsageHandler { + h := &UsageHandler{ + Router: httprouter.New(), + } + + h.HandlerFunc("GET", "/v1/usage", h.handleGetUsage) + return h +} + +// handleGetUsage is the HTTP handler for the GET /v1/usage route. +func (h *UsageHandler) handleGetUsage(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + req, err := decodeGetUsageRequest(ctx, r) + if err != nil { + errors.EncodeHTTP(ctx, err, w) + return + } + + b, err := h.UsageService.GetUsage(ctx, req.filter) + if err != nil { + errors.EncodeHTTP(ctx, err, w) + return + } + + if err := encodeResponse(ctx, w, http.StatusOK, b); err != nil { + errors.EncodeHTTP(ctx, err, w) + return + } +} + +type getUsageRequest struct { + filter platform.UsageFilter +} + +func decodeGetUsageRequest(ctx context.Context, r *http.Request) (*getUsageRequest, error) { + req := &getUsageRequest{} + qp := r.URL.Query() + + orgID := qp.Get("orgID") + if orgID != "" { + var id platform.ID + if err := (&id).DecodeFromString(orgID); err != nil { + return nil, err + } + req.filter.OrgID = &id + } + + bucketID := qp.Get("bucketID") + if bucketID != "" { + var id platform.ID + if err := (&id).DecodeFromString(bucketID); err != nil { + return nil, err + } + req.filter.BucketID = &id + } + + start := qp.Get("start") + stop := qp.Get("stop") + + if start == "" && stop != "" { + return nil, fmt.Errorf("start query param required") + } + if start == "" && stop != "" { + return nil, fmt.Errorf("stop query param required") + } + + if start == "" && stop == "" { + now := time.Now() + month := roundToMonth(now) + + req.filter.Range = &platform.Timespan{ + Start: month, + Stop: now, + } + } + + if start != "" && stop != "" { + startTime, err := time.Parse(time.RFC3339, start) + if err != nil { + return nil, err + } + + stopTime, err := time.Parse(time.RFC3339, start) + if err != nil { + return nil, err + } + + req.filter.Range = &platform.Timespan{ + Start: startTime, + Stop: stopTime, + } + } + + return req, nil +} + +func roundToMonth(t time.Time) time.Time { + h, m, s := t.Clock() + d := t.Day() + + delta := (time.Duration(d) * 24 * time.Hour) + time.Duration(h)*time.Hour + time.Duration(m)*time.Minute + time.Duration(s)*time.Second + + return t.Add(-1 * delta).Round(time.Minute) +} diff --git a/http/user_service.go b/http/user_service.go new file mode 100644 index 0000000000..bfc4ec5d8b --- /dev/null +++ b/http/user_service.go @@ -0,0 +1,413 @@ +package http + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/influxdata/platform" + "github.com/influxdata/platform/kit/errors" + "github.com/julienschmidt/httprouter" +) + +// UserHandler represents an HTTP API handler for users. +type UserHandler struct { + *httprouter.Router + + UserService platform.UserService +} + +// NewUserHandler returns a new instance of UserHandler. +func NewUserHandler() *UserHandler { + h := &UserHandler{ + Router: httprouter.New(), + } + + h.HandlerFunc("POST", "/v1/users", h.handlePostUser) + h.HandlerFunc("GET", "/v1/users", h.handleGetUsers) + h.HandlerFunc("GET", "/v1/users/:id", h.handleGetUser) + h.HandlerFunc("PATCH", "/v1/users/:id", h.handlePatchUser) + return h +} + +// handlePostUser is the HTTP handler for the POST /v1/users route. +func (h *UserHandler) handlePostUser(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + req, err := decodePostUserRequest(ctx, r) + if err != nil { + errors.EncodeHTTP(ctx, err, w) + return + } + + if err := h.UserService.CreateUser(ctx, req.User); err != nil { + errors.EncodeHTTP(ctx, err, w) + return + } + + if err := encodeResponse(ctx, w, http.StatusCreated, req.User); err != nil { + errors.EncodeHTTP(ctx, err, w) + return + } +} + +type postUserRequest struct { + User *platform.User +} + +func decodePostUserRequest(ctx context.Context, r *http.Request) (*postUserRequest, error) { + b := &platform.User{} + if err := json.NewDecoder(r.Body).Decode(b); err != nil { + return nil, err + } + + return &postUserRequest{ + User: b, + }, nil +} + +// handleGetUser is the HTTP handler for the GET /v1/users/:id route. +func (h *UserHandler) handleGetUser(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + req, err := decodeGetUserRequest(ctx, r) + if err != nil { + errors.EncodeHTTP(ctx, err, w) + return + } + + b, err := h.UserService.FindUserByID(ctx, req.UserID) + if err != nil { + errors.EncodeHTTP(ctx, err, w) + return + } + + if err := encodeResponse(ctx, w, http.StatusOK, b); err != nil { + errors.EncodeHTTP(ctx, err, w) + return + } +} + +type getUserRequest struct { + UserID platform.ID +} + +func decodeGetUserRequest(ctx context.Context, r *http.Request) (*getUserRequest, error) { + params := httprouter.ParamsFromContext(ctx) + id := params.ByName("id") + if id == "" { + return nil, errors.InvalidDataf("url missing id") + } + + var i platform.ID + if err := (&i).Decode([]byte(id)); err != nil { + return nil, err + } + + req := &getUserRequest{ + UserID: i, + } + + return req, nil +} + +// handleGetUsers is the HTTP handler for the GET /v1/users route. +func (h *UserHandler) handleGetUsers(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + f := platform.UserFilter{} + users, _, err := h.UserService.FindUsers(ctx, f) + if err != nil { + errors.EncodeHTTP(ctx, err, w) + return + } + + if err := encodeResponse(ctx, w, http.StatusOK, users); err != nil { + errors.EncodeHTTP(ctx, err, w) + return + } +} + +// handlePatchUser is the HTTP handler for the PATH /v1/users route. +func (h *UserHandler) handlePatchUser(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + req, err := decodePatchUserRequest(ctx, r) + if err != nil { + errors.EncodeHTTP(ctx, err, w) + return + } + + b, err := h.UserService.UpdateUser(ctx, req.UserID, req.Update) + if err != nil { + errors.EncodeHTTP(ctx, err, w) + return + } + + if err := encodeResponse(ctx, w, http.StatusOK, b); err != nil { + errors.EncodeHTTP(ctx, err, w) + return + } +} + +type patchUserRequest struct { + Update platform.UserUpdate + UserID platform.ID +} + +func decodePatchUserRequest(ctx context.Context, r *http.Request) (*patchUserRequest, error) { + params := httprouter.ParamsFromContext(ctx) + id := params.ByName("id") + if id == "" { + return nil, errors.InvalidDataf("url missing id") + } + + var i platform.ID + if err := (&i).Decode([]byte(id)); err != nil { + return nil, err + } + + var upd platform.UserUpdate + if err := json.NewDecoder(r.Body).Decode(&upd); err != nil { + return nil, err + } + + return &patchUserRequest{ + Update: upd, + UserID: i, + }, nil +} + +// UserService connects to Influx via HTTP using tokens to manage users +type UserService struct { + Addr string + Token string + InsecureSkipVerify bool +} + +// FindUserByID returns a single user by ID. +func (s *UserService) FindUserByID(ctx context.Context, id platform.ID) (*platform.User, error) { + url, err := newURL(s.Addr, userIDPath(id)) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", url.String(), nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", s.Token) + + hc := newClient(url.Scheme, s.InsecureSkipVerify) + resp, err := hc.Do(req) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + dec := json.NewDecoder(resp.Body) + if resp.StatusCode != http.StatusNoContent { + var reqErr errors.Error + if err := dec.Decode(&reqErr); err != nil { + return nil, err + } + return nil, reqErr + } + + var b platform.User + if err := dec.Decode(&b); err != nil { + return nil, err + } + + return &b, nil +} + +// FindUser returns the first user that matches filter. +func (s *UserService) FindUser(ctx context.Context, filter platform.UserFilter) (*platform.User, error) { + users, n, err := s.FindUsers(ctx, filter) + if err != nil { + return nil, err + } + + if n == 0 { + return nil, fmt.Errorf("found no matching user") + } + + return users[0], nil +} + +// FindUsers returns a list of users that match filter and the total count of matching users. +// Additional options provide pagination & sorting. +func (s *UserService) FindUsers(ctx context.Context, filter platform.UserFilter, opt ...platform.FindOptions) ([]*platform.User, int, error) { + url, err := newURL(s.Addr, userPath) + if err != nil { + return nil, 0, err + } + + query := url.Query() + + req, err := http.NewRequest("GET", url.String(), nil) + if err != nil { + return nil, 0, err + } + + req.URL.RawQuery = query.Encode() + req.Header.Set("Authorization", s.Token) + + hc := newClient(url.Scheme, s.InsecureSkipVerify) + resp, err := hc.Do(req) + if err != nil { + return nil, 0, err + } + + defer resp.Body.Close() + dec := json.NewDecoder(resp.Body) + if resp.StatusCode != http.StatusOK { + var reqErr errors.Error + if err := dec.Decode(&reqErr); err != nil { + return nil, 0, err + } + return nil, 0, reqErr + } + + var bs []*platform.User + if err := dec.Decode(&bs); err != nil { + return nil, 0, err + } + + return bs, len(bs), nil +} + +const ( + userPath = "/v1/users" +) + +// CreateUser creates a new user and sets u.ID with the new identifier. +func (s *UserService) CreateUser(ctx context.Context, u *platform.User) error { + url, err := newURL(s.Addr, userPath) + if err != nil { + return err + } + + octets, err := json.Marshal(u) + if err != nil { + return err + } + + req, err := http.NewRequest("POST", url.String(), bytes.NewReader(octets)) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", s.Token) + + hc := newClient(url.Scheme, s.InsecureSkipVerify) + + resp, err := hc.Do(req) + if err != nil { + return err + } + + // TODO: this should really check the error from the headers + if resp.StatusCode != http.StatusCreated { + defer resp.Body.Close() + dec := json.NewDecoder(resp.Body) + var reqErr errors.Error + if err := dec.Decode(&reqErr); err != nil { + return err + } + return reqErr + } + + if err := json.NewDecoder(resp.Body).Decode(u); err != nil { + return err + } + + return nil +} + +// UpdateUser updates a single user with changeset. +// Returns the new user state after update. +func (s *UserService) UpdateUser(ctx context.Context, id platform.ID, upd platform.UserUpdate) (*platform.User, error) { + url, err := newURL(s.Addr, userIDPath(id)) + if err != nil { + return nil, err + } + + octets, err := json.Marshal(upd) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("PATCH", url.String(), bytes.NewReader(octets)) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", s.Token) + + hc := newClient(url.Scheme, s.InsecureSkipVerify) + + resp, err := hc.Do(req) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + dec := json.NewDecoder(resp.Body) + + if resp.StatusCode != http.StatusCreated { + var reqErr errors.Error + if err := dec.Decode(&reqErr); err != nil { + return nil, err + } + return nil, reqErr + } + + var u platform.User + if err := dec.Decode(&u); err != nil { + return nil, err + } + + return &u, nil +} + +// DeleteUser removes a user by ID. +func (s *UserService) DeleteUser(ctx context.Context, id platform.ID) error { + url, err := newURL(s.Addr, userIDPath(id)) + if err != nil { + return err + } + + req, err := http.NewRequest("DELETE", url.String(), nil) + if err != nil { + return err + } + req.Header.Set("Authorization", s.Token) + + hc := newClient(url.Scheme, s.InsecureSkipVerify) + resp, err := hc.Do(req) + if err != nil { + return err + } + + if resp.StatusCode != http.StatusNoContent { + defer resp.Body.Close() + dec := json.NewDecoder(resp.Body) + var reqErr errors.Error + if err := dec.Decode(&reqErr); err != nil { + return err + } + return reqErr + } + + return nil +} + +func userIDPath(id platform.ID) string { + return userPath + "/" + id.String() +} diff --git a/id_generator.go b/id_generator.go new file mode 100644 index 0000000000..6a4ebf57ff --- /dev/null +++ b/id_generator.go @@ -0,0 +1,55 @@ +package platform + +import ( + "encoding/hex" + "encoding/json" +) + +// ID is a unique identifier. +type ID []byte + +// Decode parses b as a hex-encoded byte-slice-string. +func (i *ID) Decode(b []byte) error { + dst := make([]byte, hex.DecodedLen(len(b))) + _, err := hex.Decode(dst, b) + if err != nil { + return err + } + *i = dst + return nil +} + +// DecodeFromString parses s as a hex-encoded string. +func (i *ID) DecodeFromString(s string) error { + return i.Decode([]byte(s)) +} + +// Encode converts ID to a hex-encoded byte-slice-string. +func (i ID) Encode() []byte { + dst := make([]byte, hex.EncodedLen(len(i))) + hex.Encode(dst, i) + return dst +} + +// String returns the ID as a hex encoded string +func (i ID) String() string { + return string(i.Encode()) +} + +// IDGenerator represents a generator for IDs. +type IDGenerator interface { + // ID creates unique byte slice ID. + ID() ID +} + +// UnmarshalJSON implements JSON unmarshaller for IDs. +func (i *ID) UnmarshalJSON(b []byte) error { + b = b[1 : len(b)-1] + return i.Decode(b) +} + +// MarshalJSON implements JSON marshaller for IDs. +func (i ID) MarshalJSON() ([]byte, error) { + id := i.Encode() + return json.Marshal(string(id[:])) +} diff --git a/kit/errors/errors.go b/kit/errors/errors.go new file mode 100644 index 0000000000..3138d22245 --- /dev/null +++ b/kit/errors/errors.go @@ -0,0 +1,74 @@ +package errors + +import ( + "fmt" + "net/http" +) + +const ( + // InternalError indicates an unexpected error condition. + InternalError = 1 + // MalformedData indicates malformed input, such as unparsable JSON. + MalformedData = 2 + // InvalidData indicates that data is well-formed, but invalid. + InvalidData = 3 + // Forbidden indicates a forbidden operation. + Forbidden = 4 +) + +// Error indicates an error with a reference code and an HTTP status code. +type Error struct { + Reference int `json:"referenceCode"` + Code int `json:"statusCode"` + Err string `json:"err"` +} + +// SetCode sets the http status code for an error. +func (e *Error) SetCode() { + switch e.Reference { + case InternalError: + e.Code = http.StatusInternalServerError + case InvalidData: + e.Code = http.StatusUnprocessableEntity + case MalformedData: + e.Code = http.StatusBadRequest + case Forbidden: + e.Code = http.StatusForbidden + default: + e.Reference = InternalError + e.Code = http.StatusInternalServerError + } +} + +// Error implements the error interface. +func (e Error) Error() string { + return fmt.Sprintf("%v (error reference code: %d)", e.Err, e.Reference) +} + +// Errorf constructs an Error with the given reference code and format. +func Errorf(ref int, format string, i ...interface{}) error { + return &Error{ + Reference: ref, + Err: fmt.Sprintf(format, i...), + } +} + +// InternalErrorf constructs an InternalError with the given format. +func InternalErrorf(format string, i ...interface{}) error { + return Errorf(InternalError, format, i...) +} + +// MalformedDataf constructs a MalformedData error with the given format. +func MalformedDataf(format string, i ...interface{}) error { + return Errorf(MalformedData, format, i...) +} + +// InvalidDataf constructs an InvalidData error with the given format. +func InvalidDataf(format string, i ...interface{}) error { + return Errorf(InvalidData, format, i...) +} + +// Forbiddenf constructs a Forbidden error with the given format. +func Forbiddenf(format string, i ...interface{}) error { + return Errorf(Forbidden, format, i...) +} diff --git a/kit/errors/http.go b/kit/errors/http.go new file mode 100644 index 0000000000..7f3877704c --- /dev/null +++ b/kit/errors/http.go @@ -0,0 +1,28 @@ +package errors + +import ( + "context" + "fmt" + "net/http" +) + +// EncodeHTTP encodes err with the appropriate status code and format, +// sets the X-Influx-Error and X-Influx-Reference headers on the response, +// and sets the response status to the corresponding status code. +func EncodeHTTP(ctx context.Context, err error, w http.ResponseWriter) { + if err == nil { + return + } + e, ok := err.(*Error) + if !ok { + e = &Error{ + Reference: InternalError, + Err: err.Error(), + } + } + e.SetCode() + + w.Header().Set("X-Influx-Error", e.Error()) + w.Header().Set("X-Influx-Reference", fmt.Sprintf("%d", e.Reference)) + w.WriteHeader(e.Code) +} diff --git a/mock/bucket_service.go b/mock/bucket_service.go new file mode 100644 index 0000000000..b6e49acb8e --- /dev/null +++ b/mock/bucket_service.go @@ -0,0 +1,82 @@ +package mock + +import ( + "context" + + "github.com/influxdata/platform" + "go.uber.org/zap" +) + +// BucketService is a mock implementation of a retention.BucketService, which +// also makes it a suitable mock to use wherever an platform.BucketService is required. +type BucketService struct { + // Methods for a retention.BucketService + OpenFn func() error + CloseFn func() error + WithLoggerFn func(l *zap.Logger) + + // Methods for an platform.BucketService + FindBucketByIDFn func(context.Context, platform.ID) (*platform.Bucket, error) + FindBucketFn func(context.Context, platform.BucketFilter) (*platform.Bucket, error) + FindBucketsFn func(context.Context, platform.BucketFilter, ...platform.FindOptions) ([]*platform.Bucket, int, error) + CreateBucketFn func(context.Context, *platform.Bucket) error + UpdateBucketFn func(context.Context, platform.ID, platform.BucketUpdate) (*platform.Bucket, error) + DeleteBucketFn func(context.Context, platform.ID) error +} + +// NewBucketService returns an mock BucketService where its methods will return +// zero values. +func NewBucketService() *BucketService { + return &BucketService{ + OpenFn: func() error { return nil }, + CloseFn: func() error { return nil }, + WithLoggerFn: func(l *zap.Logger) {}, + FindBucketByIDFn: func(context.Context, platform.ID) (*platform.Bucket, error) { return nil, nil }, + FindBucketFn: func(context.Context, platform.BucketFilter) (*platform.Bucket, error) { return nil, nil }, + FindBucketsFn: func(context.Context, platform.BucketFilter, ...platform.FindOptions) ([]*platform.Bucket, int, error) { + return nil, 0, nil + }, + CreateBucketFn: func(context.Context, *platform.Bucket) error { return nil }, + UpdateBucketFn: func(context.Context, platform.ID, platform.BucketUpdate) (*platform.Bucket, error) { return nil, nil }, + DeleteBucketFn: func(context.Context, platform.ID) error { return nil }, + } +} + +// Open opens the BucketService. +func (s *BucketService) Open() error { return s.OpenFn() } + +// Close closes the BucketService. +func (s *BucketService) Close() error { return s.CloseFn() } + +// WithLogger sets the logger on the BucketService. +func (s *BucketService) WithLogger(l *zap.Logger) { s.WithLoggerFn(l) } + +// FindBucketByID returns a single bucket by ID. +func (s *BucketService) FindBucketByID(ctx context.Context, id platform.ID) (*platform.Bucket, error) { + return s.FindBucketByIDFn(ctx, id) +} + +// FindBucket returns the first bucket that matches filter. +func (s *BucketService) FindBucket(ctx context.Context, filter platform.BucketFilter) (*platform.Bucket, error) { + return s.FindBucketFn(ctx, filter) +} + +// FindBuckets returns a list of buckets that match filter and the total count of matching buckets. +func (s *BucketService) FindBuckets(ctx context.Context, filter platform.BucketFilter, opts ...platform.FindOptions) ([]*platform.Bucket, int, error) { + return s.FindBucketsFn(ctx, filter, opts...) +} + +// CreateBucket creates a new bucket and sets b.ID with the new identifier. +func (s *BucketService) CreateBucket(ctx context.Context, bucket *platform.Bucket) error { + return s.CreateBucketFn(ctx, bucket) +} + +// UpdateBucket updates a single bucket with changeset. +func (s *BucketService) UpdateBucket(ctx context.Context, id platform.ID, upd platform.BucketUpdate) (*platform.Bucket, error) { + return s.UpdateBucketFn(ctx, id, upd) +} + +// DeleteBucket removes a bucket by ID. +func (s *BucketService) DeleteBucket(ctx context.Context, id platform.ID) error { + return s.DeleteBucket(ctx, id) +} diff --git a/mock/generators.go b/mock/generators.go new file mode 100644 index 0000000000..1c49ca82d0 --- /dev/null +++ b/mock/generators.go @@ -0,0 +1,43 @@ +package mock + +import ( + "github.com/influxdata/platform" +) + +// IDGenerator is mock implementation of platform.IDGenerator. +type IDGenerator struct { + IDFn func() platform.ID +} + +// ID generates a new platform.ID from a mock function. +func (g IDGenerator) ID() platform.ID { + return g.IDFn() +} + +// NewIDGenerator is a simple way to create immutable id generator +func NewIDGenerator(s string) IDGenerator { + return IDGenerator{ + IDFn: func() platform.ID { + return platform.ID(s) + }, + } +} + +// NewTokenGenerator is a simple way to create immutable token generator. +func NewTokenGenerator(s string, err error) TokenGenerator { + return TokenGenerator{ + TokenFn: func() (string, error) { + return s, err + }, + } +} + +// TokenGenerator is mock implementation of platform.TokenGenerator. +type TokenGenerator struct { + TokenFn func() (string, error) +} + +// Token generates a new platform.Token from a mock function. +func (g TokenGenerator) Token() (string, error) { + return g.TokenFn() +} diff --git a/organization.go b/organization.go new file mode 100644 index 0000000000..e16ef71da7 --- /dev/null +++ b/organization.go @@ -0,0 +1,44 @@ +package platform + +import "context" + +// Organization is a organization. 🎉 +type Organization struct { + ID ID `json:"id"` + Name string `json:"name"` +} + +// OrganizationService represents a service for managing organization data. +type OrganizationService interface { + // Returns a single organization by ID. + FindOrganizationByID(ctx context.Context, id ID) (*Organization, error) + + // Returns the first organization that matches filter. + FindOrganization(ctx context.Context, filter OrganizationFilter) (*Organization, error) + + // Returns a list of organizations that match filter and the total count of matching organizations. + // Additional options provide pagination & sorting. + FindOrganizations(ctx context.Context, filter OrganizationFilter, opt ...FindOptions) ([]*Organization, int, error) + + // Creates a new organization and sets b.ID with the new identifier. + CreateOrganization(ctx context.Context, b *Organization) error + + // Updates a single organization with changeset. + // Returns the new organization state after update. + UpdateOrganization(ctx context.Context, id ID, upd OrganizationUpdate) (*Organization, error) + + // Removes a organization by ID. + DeleteOrganization(ctx context.Context, id ID) error +} + +// OrganizationUpdate represents updates to a organization. +// Only fields which are set are updated. +type OrganizationUpdate struct { + Name *string +} + +// OrganizationFilter represents a set of filter that restrict the returned results. +type OrganizationFilter struct { + Name *string + ID *ID +} diff --git a/prometheus/auth_service.go b/prometheus/auth_service.go new file mode 100644 index 0000000000..f6ca3075b8 --- /dev/null +++ b/prometheus/auth_service.go @@ -0,0 +1,102 @@ +package prometheus + +import ( + "context" + "fmt" + "time" + + "github.com/influxdata/platform" + "github.com/prometheus/client_golang/prometheus" +) + +// AuthorizationService manages authorizations. +type AuthorizationService struct { + requestCount *prometheus.CounterVec + requestDuration *prometheus.HistogramVec + AuthorizationService platform.AuthorizationService +} + +// NewAuthorizationService creates an instance of AuthorizationService. +func NewAuthorizationService() *AuthorizationService { + // TODO: what to make these values + namespace := "auth" + subsystem := "prometheus" + s := &AuthorizationService{ + requestCount: prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: namespace, + Subsystem: subsystem, + Name: "requests_total", + Help: "Number of http requests received", + }, []string{"method", "error"}), + requestDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: namespace, + Subsystem: subsystem, + Name: "request_duration_seconds", + Help: "Time taken to respond to HTTP request", + // TODO(desa): determine what spacing these buckets should have. + Buckets: prometheus.ExponentialBuckets(0.001, 1.5, 25), + }, []string{"method", "error"}), + } + + prometheus.MustRegister( + s.requestCount, + s.requestDuration, + ) + + return s +} + +// FindAuthorizationByToken returns an authorization given a token, records function call latency, and counts function calls. +func (s *AuthorizationService) FindAuthorizationByToken(ctx context.Context, t string) (a *platform.Authorization, err error) { + defer func(start time.Time) { + labels := prometheus.Labels{ + "method": "FindAuthorizationsByToken", + "error": fmt.Sprint(err != nil), + } + s.requestCount.With(labels).Add(1) + s.requestDuration.With(labels).Observe(time.Since(start).Seconds()) + }(time.Now()) + return s.AuthorizationService.FindAuthorizationByToken(ctx, t) +} + +// FindAuthorizations returns authorizations given a filter, records function call latency, and counts function calls. +func (s *AuthorizationService) FindAuthorizations(ctx context.Context, filter platform.AuthorizationFilter, opt ...platform.FindOptions) (as []*platform.Authorization, i int, err error) { + defer func(start time.Time) { + labels := prometheus.Labels{ + "method": "FindAuthorizations", + "error": fmt.Sprint(err != nil), + } + s.requestCount.With(labels).Add(1) + s.requestDuration.With(labels).Observe(time.Since(start).Seconds()) + }(time.Now()) + + return s.AuthorizationService.FindAuthorizations(ctx, filter, opt...) +} + +// CreateAuthorization creates an authorization, records function call latency, and counts function calls. +func (s *AuthorizationService) CreateAuthorization(ctx context.Context, a *platform.Authorization) (err error) { + defer func(start time.Time) { + labels := prometheus.Labels{ + "method": "CreateAuthorization", + "error": fmt.Sprint(err != nil), + } + s.requestCount.With(labels).Add(1) + s.requestDuration.With(labels).Observe(time.Since(start).Seconds()) + }(time.Now()) + + return s.AuthorizationService.CreateAuthorization(ctx, a) +} + +// DeleteAuthorization deletes an authorization, records function call latency, and counts function calls. +func (s *AuthorizationService) DeleteAuthorization(ctx context.Context, t string) (err error) { + defer func(start time.Time) { + labels := prometheus.Labels{ + "method": "DeleteAuthorization", + "error": fmt.Sprint(err != nil), + } + s.requestCount.With(labels).Add(1) + s.requestDuration.With(labels).Observe(time.Since(start).Seconds()) + }(time.Now()) + + return s.AuthorizationService.DeleteAuthorization(ctx, t) +} diff --git a/rand/token_generator.go b/rand/token_generator.go new file mode 100644 index 0000000000..e27f17804f --- /dev/null +++ b/rand/token_generator.go @@ -0,0 +1,39 @@ +package rand + +import ( + "crypto/rand" + "encoding/base64" + + "github.com/influxdata/platform" +) + +// TokenGenerator implements platform.TokenGenerator. +type TokenGenerator struct { + size int +} + +// NewTokenGenerator creates an instance of an platform.TokenGenerator. +func NewTokenGenerator(n int) platform.TokenGenerator { + return &TokenGenerator{ + size: n, + } +} + +// Token returns a new string token of size t.size. +func (t *TokenGenerator) Token() (string, error) { + return generateRandomString(t.size) +} + +func generateRandomString(s int) (string, error) { + b, err := generateRandomBytes(s) + return base64.URLEncoding.EncodeToString(b), err +} + +func generateRandomBytes(n int) ([]byte, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return nil, err + } + + return b, nil +} diff --git a/snowflake/id_generator.go b/snowflake/id_generator.go new file mode 100644 index 0000000000..dc976a5db7 --- /dev/null +++ b/snowflake/id_generator.go @@ -0,0 +1,32 @@ +package snowflake + +import ( + "encoding/binary" + "math/rand" + "time" + + "github.com/influxdata/platform" + "github.com/influxdata/influxdb/pkg/snowflake" +) + +func init() { + rand.Seed(time.Now().UnixNano()) +} + +type IDGenerator struct { + Generator *snowflake.Generator +} + +func NewIDGenerator() *IDGenerator { + return &IDGenerator{ + // Maximum machine id is 1023 + Generator: snowflake.New(rand.Intn(1023)), + } +} + +func (g *IDGenerator) ID() platform.ID { + id := make(platform.ID, 8) + i := g.Generator.Next() + binary.BigEndian.PutUint64(id, i) + return id +} diff --git a/snowflake/id_generator_test.go b/snowflake/id_generator_test.go new file mode 100644 index 0000000000..ea286292b8 --- /dev/null +++ b/snowflake/id_generator_test.go @@ -0,0 +1,27 @@ +package snowflake + +import ( + "bytes" + "testing" + + "github.com/influxdata/platform" +) + +func TestIDLength(t *testing.T) { + gen := NewIDGenerator() + id := gen.ID() + if len(id) != 8 { + t.Fail() + } +} + +func TestToFromString(t *testing.T) { + gen := NewIDGenerator() + id := gen.ID() + var clone platform.ID + if err := clone.DecodeFromString(id.String()); err != nil { + t.Error(err) + } else if !bytes.Equal(id, clone) { + t.Errorf("id started as %x but got back %x", id, clone) + } +} diff --git a/testing/user_service.go b/testing/user_service.go new file mode 100644 index 0000000000..adc3619828 --- /dev/null +++ b/testing/user_service.go @@ -0,0 +1,622 @@ +package testing + +import ( + "bytes" + "context" + "fmt" + "sort" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/influxdata/platform" + "github.com/influxdata/platform/mock" +) + +var userCmpOptions = cmp.Options{ + cmp.Comparer(func(x, y []byte) bool { + return bytes.Equal(x, y) + }), + cmp.Transformer("Sort", func(in []*platform.User) []*platform.User { + out := append([]*platform.User(nil), in...) // Copy input to avoid mutating it + sort.Slice(out, func(i, j int) bool { + return out[i].ID.String() > out[j].ID.String() + }) + return out + }), +} + +// UserFields will include the IDGenerator, and users +type UserFields struct { + IDGenerator platform.IDGenerator + Users []*platform.User +} + +// CreateUser testing +func CreateUser( + init func(UserFields, *testing.T) (platform.UserService, func()), + t *testing.T, +) { + type args struct { + user *platform.User + } + type wants struct { + err error + users []*platform.User + } + + tests := []struct { + name string + fields UserFields + args args + wants wants + }{ + { + name: "create users with empty set", + fields: UserFields{ + IDGenerator: mock.NewIDGenerator("id1"), + Users: []*platform.User{}, + }, + args: args{ + user: &platform.User{ + Name: "name1", + }, + }, + wants: wants{ + users: []*platform.User{ + { + Name: "name1", + ID: platform.ID("id1"), + }, + }, + }, + }, + { + name: "basic create user", + fields: UserFields{ + IDGenerator: &mock.IDGenerator{ + IDFn: func() platform.ID { + return platform.ID("2") + }, + }, + Users: []*platform.User{ + { + ID: platform.ID("1"), + Name: "user1", + }, + }, + }, + args: args{ + user: &platform.User{ + Name: "user2", + }, + }, + wants: wants{ + users: []*platform.User{ + { + ID: platform.ID("1"), + Name: "user1", + }, + { + ID: platform.ID("2"), + Name: "user2", + }, + }, + }, + }, + { + name: "names should be unique", + fields: UserFields{ + IDGenerator: &mock.IDGenerator{ + IDFn: func() platform.ID { + return platform.ID("2") + }, + }, + Users: []*platform.User{ + { + ID: platform.ID("1"), + Name: "user1", + }, + }, + }, + args: args{ + user: &platform.User{ + Name: "user1", + }, + }, + wants: wants{ + users: []*platform.User{ + { + ID: platform.ID("1"), + Name: "user1", + }, + }, + err: fmt.Errorf("user with name user1 already exists"), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s, done := init(tt.fields, t) + defer done() + ctx := context.TODO() + err := s.CreateUser(ctx, tt.args.user) + if (err != nil) != (tt.wants.err != nil) { + t.Fatalf("expected error '%v' got '%v'", tt.wants.err, err) + } + + if err != nil && tt.wants.err != nil { + if err.Error() != tt.wants.err.Error() { + t.Fatalf("expected error messages to match '%v' got '%v'", tt.wants.err, err.Error()) + } + } + defer s.DeleteUser(ctx, tt.args.user.ID) + + users, _, err := s.FindUsers(ctx, platform.UserFilter{}) + if err != nil { + t.Fatalf("failed to retrieve users: %v", err) + } + if diff := cmp.Diff(users, tt.wants.users, userCmpOptions...); diff != "" { + t.Errorf("users are different -got/+want\ndiff %s", diff) + } + }) + } +} + +// FindUserByID testing +func FindUserByID( + init func(UserFields, *testing.T) (platform.UserService, func()), + t *testing.T, +) { + type args struct { + id platform.ID + } + type wants struct { + err error + user *platform.User + } + + tests := []struct { + name string + fields UserFields + args args + wants wants + }{ + { + name: "basic find user by id", + fields: UserFields{ + Users: []*platform.User{ + { + ID: platform.ID("1"), + Name: "user1", + }, + { + ID: platform.ID("2"), + Name: "user2", + }, + }, + }, + args: args{ + id: platform.ID("2"), + }, + wants: wants{ + user: &platform.User{ + ID: platform.ID("2"), + Name: "user2", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s, done := init(tt.fields, t) + defer done() + ctx := context.TODO() + + user, err := s.FindUserByID(ctx, tt.args.id) + if (err != nil) != (tt.wants.err != nil) { + t.Fatalf("expected errors to be equal '%v' got '%v'", tt.wants.err, err) + } + + if err != nil && tt.wants.err != nil { + if err.Error() != tt.wants.err.Error() { + t.Fatalf("expected error '%v' got '%v'", tt.wants.err, err) + } + } + + if diff := cmp.Diff(user, tt.wants.user, userCmpOptions...); diff != "" { + t.Errorf("user is different -got/+want\ndiff %s", diff) + } + }) + } +} + +// FindUsers testing +func FindUsers( + init func(UserFields, *testing.T) (platform.UserService, func()), + t *testing.T, +) { + type args struct { + ID string + name string + } + + type wants struct { + users []*platform.User + err error + } + tests := []struct { + name string + fields UserFields + args args + wants wants + }{ + { + name: "find all users", + fields: UserFields{ + Users: []*platform.User{ + { + ID: platform.ID("test1"), + Name: "abc", + }, + { + ID: platform.ID("test2"), + Name: "xyz", + }, + }, + }, + args: args{}, + wants: wants{ + users: []*platform.User{ + { + ID: platform.ID("test1"), + Name: "abc", + }, + { + ID: platform.ID("test2"), + Name: "xyz", + }, + }, + }, + }, + { + name: "find user by id", + fields: UserFields{ + Users: []*platform.User{ + { + ID: platform.ID("test1"), + Name: "abc", + }, + { + ID: platform.ID("test2"), + Name: "xyz", + }, + }, + }, + args: args{ + ID: "test2", + }, + wants: wants{ + users: []*platform.User{ + { + ID: platform.ID("test2"), + Name: "xyz", + }, + }, + }, + }, + { + name: "find user by name", + fields: UserFields{ + Users: []*platform.User{ + { + ID: platform.ID("test1"), + Name: "abc", + }, + { + ID: platform.ID("test2"), + Name: "xyz", + }, + }, + }, + args: args{ + name: "xyz", + }, + wants: wants{ + users: []*platform.User{ + { + ID: platform.ID("test2"), + Name: "xyz", + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s, done := init(tt.fields, t) + defer done() + ctx := context.TODO() + + filter := platform.UserFilter{} + if tt.args.ID != "" { + id := platform.ID(tt.args.ID) + filter.ID = &id + } + if tt.args.name != "" { + filter.Name = &tt.args.name + } + + users, _, err := s.FindUsers(ctx, filter) + if (err != nil) != (tt.wants.err != nil) { + t.Fatalf("expected errors to be equal '%v' got '%v'", tt.wants.err, err) + } + + if err != nil && tt.wants.err != nil { + if err.Error() != tt.wants.err.Error() { + t.Fatalf("expected error '%v' got '%v'", tt.wants.err, err) + } + } + + if diff := cmp.Diff(users, tt.wants.users, userCmpOptions...); diff != "" { + t.Errorf("users are different -got/+want\ndiff %s", diff) + } + }) + } +} + +// DeleteUser testing +func DeleteUser( + init func(UserFields, *testing.T) (platform.UserService, func()), + t *testing.T, +) { + type args struct { + ID string + } + type wants struct { + err error + users []*platform.User + } + + tests := []struct { + name string + fields UserFields + args args + wants wants + }{ + { + name: "delete users using exist id", + fields: UserFields{ + Users: []*platform.User{ + { + Name: "orgA", + ID: platform.ID("abc"), + }, + { + Name: "orgB", + ID: platform.ID("xyz"), + }, + }, + }, + args: args{ + ID: "abc", + }, + wants: wants{ + users: []*platform.User{ + { + Name: "orgB", + ID: platform.ID("xyz"), + }, + }, + }, + }, + { + name: "delete users using exist id", + fields: UserFields{ + Users: []*platform.User{ + { + Name: "orgA", + ID: platform.ID("abc"), + }, + { + Name: "orgB", + ID: platform.ID("xyz"), + }, + }, + }, + args: args{ + ID: "123", + }, + wants: wants{ + users: []*platform.User{ + { + Name: "orgA", + ID: platform.ID("abc"), + }, + { + Name: "orgB", + ID: platform.ID("xyz"), + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s, done := init(tt.fields, t) + defer done() + ctx := context.TODO() + err := s.DeleteUser(ctx, platform.ID(tt.args.ID)) + if (err != nil) != (tt.wants.err != nil) { + t.Fatalf("expected error '%v' got '%v'", tt.wants.err, err) + } + + if err != nil && tt.wants.err != nil { + if err.Error() != tt.wants.err.Error() { + t.Fatalf("expected error messages to match '%v' got '%v'", tt.wants.err, err.Error()) + } + } + + filter := platform.UserFilter{} + users, _, err := s.FindUsers(ctx, filter) + if err != nil { + t.Fatalf("failed to retrieve users: %v", err) + } + if diff := cmp.Diff(users, tt.wants.users, userCmpOptions...); diff != "" { + t.Errorf("users are different -got/+want\ndiff %s", diff) + } + }) + } +} + +// FindUser testing +func FindUser( + init func(UserFields, *testing.T) (platform.UserService, func()), + t *testing.T, +) { + type args struct { + name string + } + + type wants struct { + user *platform.User + err error + } + + tests := []struct { + name string + fields UserFields + args args + wants wants + }{ + { + name: "find user by name", + fields: UserFields{ + Users: []*platform.User{ + { + ID: platform.ID("a"), + Name: "abc", + }, + { + ID: platform.ID("b"), + Name: "xyz", + }, + }, + }, + args: args{ + name: "abc", + }, + wants: wants{ + user: &platform.User{ + ID: platform.ID("a"), + Name: "abc", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s, done := init(tt.fields, t) + defer done() + ctx := context.TODO() + filter := platform.UserFilter{} + if tt.args.name != "" { + filter.Name = &tt.args.name + } + + user, err := s.FindUser(ctx, filter) + if (err != nil) != (tt.wants.err != nil) { + t.Fatalf("expected error '%v' got '%v'", tt.wants.err, err) + } + + if err != nil && tt.wants.err != nil { + if err.Error() != tt.wants.err.Error() { + t.Fatalf("expected error messages to match '%v' got '%v'", tt.wants.err, err.Error()) + } + } + + if diff := cmp.Diff(user, tt.wants.user, userCmpOptions...); diff != "" { + t.Errorf("users are different -got/+want\ndiff %s", diff) + } + }) + } +} + +// UpdateUser testing +func UpdateUser( + init func(UserFields, *testing.T) (platform.UserService, func()), + t *testing.T, +) { + type args struct { + name string + id platform.ID + } + type wants struct { + err error + user *platform.User + } + + tests := []struct { + name string + fields UserFields + args args + wants wants + }{ + { + name: "basic find all users", + fields: UserFields{ + Users: []*platform.User{ + { + ID: platform.ID("1"), + Name: "user1", + }, + { + ID: platform.ID("2"), + Name: "user2", + }, + }, + }, + args: args{ + id: platform.ID("1"), + name: "changed", + }, + wants: wants{ + user: &platform.User{ + ID: platform.ID("1"), + Name: "changed", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s, done := init(tt.fields, t) + defer done() + ctx := context.TODO() + + upd := platform.UserUpdate{ + Name: &tt.args.name, + } + + user, err := s.UpdateUser(ctx, tt.args.id, upd) + if (err != nil) != (tt.wants.err != nil) { + t.Fatalf("expected error '%v' got '%v'", tt.wants.err, err) + } + + if err != nil && tt.wants.err != nil { + if err.Error() != tt.wants.err.Error() { + t.Fatalf("expected error messages to match '%v' got '%v'", tt.wants.err, err.Error()) + } + } + + if diff := cmp.Diff(user, tt.wants.user, userCmpOptions...); diff != "" { + t.Errorf("user is different -got/+want\ndiff %s", diff) + } + }) + } +} diff --git a/token_generator.go b/token_generator.go new file mode 100644 index 0000000000..841d9aa25a --- /dev/null +++ b/token_generator.go @@ -0,0 +1,7 @@ +package platform + +// TokenGenerator represents a generator for API tokens. +type TokenGenerator interface { + // Token generates a new API token. + Token() (string, error) +} diff --git a/usage.go b/usage.go new file mode 100644 index 0000000000..32e69efabd --- /dev/null +++ b/usage.go @@ -0,0 +1,42 @@ +package platform + +import ( + "context" + "time" +) + +// UsageMetric used to track classes of usage. +type UsageMetric string + +const ( + // UsageWriteRequestCount is the name of the metrics for tracking write request count. + UsageWriteRequestCount UsageMetric = "usage_write_request_count" + // UsageWriteRequestBytes is the name of the metrics for tracking the number of bytes. + UsageWriteRequestBytes UsageMetric = "usage_write_request_bytes" +) + +// Usage is a metric associated with the utilization of a particular resource. +type Usage struct { + OrganizationID *ID `json:"organizationID,omitempty"` + BucketID *ID `json:"bucketID,omitempty"` + Type UsageMetric `json:"type"` + Value float64 `json:"value"` +} + +// UsageService is a service for accessing usage statistics. +type UsageService interface { + GetUsage(ctx context.Context, filter UsageFilter) (map[UsageMetric]*Usage, error) +} + +// UsageFilter is used to filter usage. +type UsageFilter struct { + OrgID *ID + BucketID *ID + Range *Timespan +} + +// Timespan represents a range of time. +type Timespan struct { + Start time.Time `json:"start"` + Stop time.Time `json:"stop"` +} diff --git a/user.go b/user.go new file mode 100644 index 0000000000..3cdfee35a3 --- /dev/null +++ b/user.go @@ -0,0 +1,44 @@ +package platform + +import "context" + +// User is a user. 🎉 +type User struct { + ID ID `json:"id,omitempty"` + Name string `json:"name"` +} + +// UserService represents a service for managing user data. +type UserService interface { + // Returns a single user by ID. + FindUserByID(ctx context.Context, id ID) (*User, error) + + // Returns the first user that matches filter. + FindUser(ctx context.Context, filter UserFilter) (*User, error) + + // Returns a list of users that match filter and the total count of matching users. + // Additional options provide pagination & sorting. + FindUsers(ctx context.Context, filter UserFilter, opt ...FindOptions) ([]*User, int, error) + + // Creates a new user and sets u.ID with the new identifier. + CreateUser(ctx context.Context, u *User) error + + // Updates a single user with changeset. + // Returns the new user state after update. + UpdateUser(ctx context.Context, id ID, upd UserUpdate) (*User, error) + + // Removes a user by ID. + DeleteUser(ctx context.Context, id ID) error +} + +// UserUpdate represents updates to a user. +// Only fields which are set are updated. +type UserUpdate struct { + Name *string `json:"name"` +} + +// UserFilter represents a set of filter that restrict the returned results. +type UserFilter struct { + ID *ID + Name *string +} diff --git a/zap/auth_service.go b/zap/auth_service.go new file mode 100644 index 0000000000..67a31203b4 --- /dev/null +++ b/zap/auth_service.go @@ -0,0 +1,59 @@ +package logger + +import ( + "context" + + "go.uber.org/zap" + + "github.com/influxdata/platform" +) + +// AuthorizationService manages authorizations. +type AuthorizationService struct { + Logger *zap.Logger + AuthorizationService platform.AuthorizationService +} + +// FindAuthorizationByToken returns an authorization given a token, and logs any errors. +func (s *AuthorizationService) FindAuthorizationByToken(ctx context.Context, t string) (a *platform.Authorization, err error) { + defer func() { + if err != nil { + s.Logger.Info("error finding authorization by token", zap.Error(err)) + } + }() + + return s.AuthorizationService.FindAuthorizationByToken(ctx, t) +} + +// FindAuthorizations returns authorizations given a filter, and logs any errors. +func (s *AuthorizationService) FindAuthorizations(ctx context.Context, filter platform.AuthorizationFilter, opt ...platform.FindOptions) (as []*platform.Authorization, i int, err error) { + defer func() { + if err != nil { + s.Logger.Info("error finding authorizations", zap.Error(err)) + } + }() + + return s.AuthorizationService.FindAuthorizations(ctx, filter, opt...) +} + +// CreateAuthorization creates an authorization, and logs any errors. +func (s *AuthorizationService) CreateAuthorization(ctx context.Context, a *platform.Authorization) (err error) { + defer func() { + if err != nil { + s.Logger.Info("error creating authorization", zap.Error(err)) + } + }() + + return s.AuthorizationService.CreateAuthorization(ctx, a) +} + +// DeleteAuthorization deletes an authorization, and logs any errors. +func (s *AuthorizationService) DeleteAuthorization(ctx context.Context, t string) (err error) { + defer func() { + if err != nil { + s.Logger.Info("error deleting authorization", zap.Error(err)) + } + }() + + return s.AuthorizationService.DeleteAuthorization(ctx, t) +}