satellite/console: Partially revert change to remove graphql

This partially reverts commit 516241e406.

Endpoints are added to the backend, as there are some customers who may
use these endpoints, even though they are no longer necessary for the
satellite UI.

Change-Id: I52a99912d9eacf269fbb2ddca603e53c4af6d6bf
This commit is contained in:
Moby von Briesen 2023-09-07 15:11:20 -04:00 committed by Storj Robot
parent 754bf5f8af
commit 8d1a765fd6
23 changed files with 4196 additions and 1 deletions

1
go.mod
View File

@ -16,6 +16,7 @@ require (
github.com/google/go-cmp v0.5.9
github.com/gorilla/mux v1.8.0
github.com/gorilla/schema v1.2.0
github.com/graphql-go/graphql v0.7.9
github.com/jackc/pgerrcode v0.0.0-20201024163028-a0d42d470451
github.com/jackc/pgtype v1.14.0
github.com/jackc/pgx/v5 v5.3.1

2
go.sum
View File

@ -229,6 +229,8 @@ github.com/gorilla/schema v1.2.0 h1:YufUaxZYCKGFuAq3c96BOhjgd5nmXiOY9NGzF247Tsc=
github.com/gorilla/schema v1.2.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/graphql-go/graphql v0.7.9 h1:5Va/Rt4l5g3YjwDnid3vFfn43faaQBq7rMcIZ0VnV34=
github.com/graphql-go/graphql v0.7.9/go.mod h1:k6yrAYQaSP59DC5UVxbgxESlmVyojThKdORUqGDGmrI=
github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=

View File

@ -0,0 +1,134 @@
// Copyright (C) 2019 Storj Labs, Inc.
// See LICENSE for copying information.
package consoleql
import (
"github.com/graphql-go/graphql"
"storj.io/storj/satellite/console"
)
const (
// APIKeyInfoType is graphql type name for api key.
APIKeyInfoType = "keyInfo"
// CreateAPIKeyType is graphql type name for createAPIKey struct
// which incapsulates the actual key and it's info.
CreateAPIKeyType = "graphqlCreateAPIKey"
// FieldKey is field name for the actual key in createAPIKey.
FieldKey = "key"
)
// graphqlAPIKeyInfo creates satellite.APIKeyInfo graphql object.
func graphqlAPIKeyInfo() *graphql.Object {
return graphql.NewObject(graphql.ObjectConfig{
Name: APIKeyInfoType,
Fields: graphql.Fields{
FieldID: &graphql.Field{
Type: graphql.String,
},
FieldProjectID: &graphql.Field{
Type: graphql.String,
},
FieldName: &graphql.Field{
Type: graphql.String,
},
FieldCreatedAt: &graphql.Field{
Type: graphql.DateTime,
},
},
})
}
// graphqlCreateAPIKey creates createAPIKey graphql object.
func graphqlCreateAPIKey(types *TypeCreator) *graphql.Object {
return graphql.NewObject(graphql.ObjectConfig{
Name: CreateAPIKeyType,
Fields: graphql.Fields{
FieldKey: &graphql.Field{
Type: graphql.String,
},
APIKeyInfoType: &graphql.Field{
Type: types.apiKeyInfo,
},
},
})
}
func graphqlAPIKeysCursor() *graphql.InputObject {
return graphql.NewInputObject(graphql.InputObjectConfig{
Name: APIKeysCursorInputType,
Fields: graphql.InputObjectConfigFieldMap{
SearchArg: &graphql.InputObjectFieldConfig{
Type: graphql.NewNonNull(graphql.String),
},
LimitArg: &graphql.InputObjectFieldConfig{
Type: graphql.NewNonNull(graphql.Int),
},
PageArg: &graphql.InputObjectFieldConfig{
Type: graphql.NewNonNull(graphql.Int),
},
OrderArg: &graphql.InputObjectFieldConfig{
Type: graphql.NewNonNull(graphql.Int),
},
OrderDirectionArg: &graphql.InputObjectFieldConfig{
Type: graphql.NewNonNull(graphql.Int),
},
},
})
}
func graphqlAPIKeysPage(types *TypeCreator) *graphql.Object {
return graphql.NewObject(graphql.ObjectConfig{
Name: APIKeysPageType,
Fields: graphql.Fields{
FieldAPIKeys: &graphql.Field{
Type: graphql.NewList(types.apiKeyInfo),
},
SearchArg: &graphql.Field{
Type: graphql.String,
},
LimitArg: &graphql.Field{
Type: graphql.Int,
},
OrderArg: &graphql.Field{
Type: graphql.Int,
},
OrderDirectionArg: &graphql.Field{
Type: graphql.Int,
},
OffsetArg: &graphql.Field{
Type: graphql.Int,
},
FieldPageCount: &graphql.Field{
Type: graphql.Int,
},
FieldCurrentPage: &graphql.Field{
Type: graphql.Int,
},
FieldTotalCount: &graphql.Field{
Type: graphql.Int,
},
},
})
}
// createAPIKey holds macaroon.APIKey and console.APIKeyInfo.
type createAPIKey struct {
Key string
KeyInfo *console.APIKeyInfo
}
type apiKeysPage struct {
APIKeys []console.APIKeyInfo
Search string
Limit uint
Order int
OrderDirection int
Offset uint64
PageCount uint
CurrentPage uint
TotalCount uint64
}

View File

@ -0,0 +1,23 @@
// Copyright (C) 2022 Storj Labs, Inc.
// See LICENSE for copying information.
package consoleql
const (
// ActivationPath is key for path which handles account activation.
ActivationPath = "activationPath"
// PasswordRecoveryPath is key for path which handles password recovery.
PasswordRecoveryPath = "passwordRecoveryPath"
// CancelPasswordRecoveryPath is key for path which handles let us know sequence.
CancelPasswordRecoveryPath = "cancelPasswordRecoveryPath"
// SignInPath is key for sign in server route.
SignInPath = "signInPath"
// LetUsKnowURL is key to store let us know URL.
LetUsKnowURL = "letUsKnowURL"
// ContactInfoURL is a key to store contact info URL.
ContactInfoURL = "contactInfoURL"
// TermsAndConditionsURL is a key to store terms and conditions URL.
TermsAndConditionsURL = "termsAndConditionsURL"
// SatelliteRegion is a key to store the satellite's region/name.
SatelliteRegion = "satelliteRegion"
)

View File

@ -0,0 +1,372 @@
// Copyright (C) 2019 Storj Labs, Inc.
// See LICENSE for copying information.
package consoleql
import (
"github.com/graphql-go/graphql"
"github.com/zeebo/errs"
"go.uber.org/zap"
"storj.io/common/uuid"
"storj.io/storj/private/post"
"storj.io/storj/satellite/console"
"storj.io/storj/satellite/mailservice"
)
const (
// Mutation is graphql request that modifies data.
Mutation = "mutation"
// CreateProjectMutation is a mutation name for project creation.
CreateProjectMutation = "createProject"
// DeleteProjectMutation is a mutation name for project deletion.
DeleteProjectMutation = "deleteProject"
// UpdateProjectMutation is a mutation name for project name and description updating.
UpdateProjectMutation = "updateProject"
// AddProjectMembersMutation is a mutation name for adding new project members.
AddProjectMembersMutation = "addProjectMembers"
// DeleteProjectMembersMutation is a mutation name for deleting project members.
DeleteProjectMembersMutation = "deleteProjectMembers"
// CreateAPIKeyMutation is a mutation name for api key creation.
CreateAPIKeyMutation = "createAPIKey"
// DeleteAPIKeysMutation is a mutation name for api key deleting.
DeleteAPIKeysMutation = "deleteAPIKeys"
// AddPaymentMethodMutation is mutation name for adding new payment method.
AddPaymentMethodMutation = "addPaymentMethod"
// DeletePaymentMethodMutation is mutation name for deleting payment method.
DeletePaymentMethodMutation = "deletePaymentMethod"
// SetDefaultPaymentMethodMutation is mutation name setting payment method as default payment method.
SetDefaultPaymentMethodMutation = "setDefaultPaymentMethod"
// InputArg is argument name for all input types.
InputArg = "input"
// ProjectFields is a field name for project specific fields.
ProjectFields = "projectFields"
// ProjectLimits is a field name for project specific limits.
ProjectLimits = "projectLimits"
// FieldProjectID is field name for projectID.
FieldProjectID = "projectID"
// FieldNewPassword is a field name for new password.
FieldNewPassword = "newPassword"
// Secret is a field name for registration token for user creation during Vanguard release.
Secret = "secret"
// ReferrerUserID is a field name for passing referrer's user id.
ReferrerUserID = "referrerUserId"
)
// rootMutation creates mutation for graphql populated by AccountsClient.
func rootMutation(log *zap.Logger, service *console.Service, mailService *mailservice.Service, types *TypeCreator) *graphql.Object {
return graphql.NewObject(graphql.ObjectConfig{
Name: Mutation,
Fields: graphql.Fields{
// creates project from input params
CreateProjectMutation: &graphql.Field{
Type: types.project,
Args: graphql.FieldConfigArgument{
InputArg: &graphql.ArgumentConfig{
Type: graphql.NewNonNull(types.projectInput),
},
},
Resolve: func(p graphql.ResolveParams) (interface{}, error) {
var projectInput = fromMapProjectInfo(p.Args[InputArg].(map[string]interface{}))
project, err := service.CreateProject(p.Context, projectInput)
if err != nil {
return nil, err
}
return project, nil
},
},
// deletes project by id, taken from input params
DeleteProjectMutation: &graphql.Field{
Type: types.project,
Args: graphql.FieldConfigArgument{
FieldID: &graphql.ArgumentConfig{
Type: graphql.String,
DefaultValue: "",
},
FieldPublicID: &graphql.ArgumentConfig{
Type: graphql.String,
DefaultValue: "",
},
},
Resolve: func(p graphql.ResolveParams) (interface{}, error) {
return nil, console.ErrUnauthorized.New("not implemented")
},
},
// updates project name and description.
UpdateProjectMutation: &graphql.Field{
Type: types.project,
Args: graphql.FieldConfigArgument{
FieldID: &graphql.ArgumentConfig{
Type: graphql.String,
DefaultValue: "",
},
FieldPublicID: &graphql.ArgumentConfig{
Type: graphql.String,
DefaultValue: "",
},
ProjectFields: &graphql.ArgumentConfig{
Type: graphql.NewNonNull(types.projectInput),
},
ProjectLimits: &graphql.ArgumentConfig{
Type: graphql.NewNonNull(types.projectLimit),
},
},
Resolve: func(p graphql.ResolveParams) (interface{}, error) {
var projectInput, err = fromMapProjectInfoProjectLimits(p.Args[ProjectFields].(map[string]interface{}), p.Args[ProjectLimits].(map[string]interface{}))
if err != nil {
return nil, err
}
projectID, err := getProjectID(p)
if err != nil {
return nil, err
}
project, err := service.UpdateProject(p.Context, projectID, projectInput)
if err != nil {
return nil, err
}
return project, nil
},
},
// add user as member of given project
AddProjectMembersMutation: &graphql.Field{
Type: types.project,
Args: graphql.FieldConfigArgument{
FieldProjectID: &graphql.ArgumentConfig{
Type: graphql.String,
DefaultValue: "",
},
FieldPublicID: &graphql.ArgumentConfig{
Type: graphql.String,
DefaultValue: "",
},
FieldEmail: &graphql.ArgumentConfig{
Type: graphql.NewNonNull(graphql.NewList(graphql.String)),
},
},
Resolve: func(p graphql.ResolveParams) (interface{}, error) {
inviter, err := console.GetUser(p.Context)
if err != nil {
return nil, err
}
emails, _ := p.Args[FieldEmail].([]interface{})
projectID, err := getProjectID(p)
if err != nil {
return nil, err
}
var userEmails []string
for _, email := range emails {
userEmails = append(userEmails, email.(string))
}
project, err := service.GetProject(p.Context, projectID)
if err != nil {
return nil, err
}
users, err := service.AddProjectMembers(p.Context, project.ID, userEmails)
if err != nil {
return nil, err
}
rootObject := p.Info.RootValue.(map[string]interface{})
origin := rootObject["origin"].(string)
signIn := origin + rootObject[SignInPath].(string)
for _, user := range users {
userName := user.ShortName
if user.ShortName == "" {
userName = user.FullName
}
satelliteRegion := rootObject[SatelliteRegion].(string)
mailService.SendRenderedAsync(
p.Context,
[]post.Address{{Address: user.Email, Name: userName}},
&console.ExistingUserProjectInvitationEmail{
InviterEmail: inviter.Email,
Region: satelliteRegion,
SignInLink: signIn,
},
)
}
return project, nil
},
},
// delete user membership for given project
DeleteProjectMembersMutation: &graphql.Field{
Type: types.project,
Args: graphql.FieldConfigArgument{
FieldProjectID: &graphql.ArgumentConfig{
Type: graphql.String,
DefaultValue: "",
},
FieldPublicID: &graphql.ArgumentConfig{
Type: graphql.String,
DefaultValue: "",
},
FieldEmail: &graphql.ArgumentConfig{
Type: graphql.NewNonNull(graphql.NewList(graphql.String)),
},
},
Resolve: func(p graphql.ResolveParams) (interface{}, error) {
emails, _ := p.Args[FieldEmail].([]interface{})
projectID, err := getProjectID(p)
if err != nil {
return nil, err
}
var userEmails []string
for _, email := range emails {
userEmails = append(userEmails, email.(string))
}
project, err := service.GetProject(p.Context, projectID)
if err != nil {
return nil, err
}
err = service.DeleteProjectMembersAndInvitations(p.Context, project.ID, userEmails)
if err != nil {
return nil, err
}
return project, nil
},
},
// creates new api key
CreateAPIKeyMutation: &graphql.Field{
Type: types.createAPIKey,
Args: graphql.FieldConfigArgument{
FieldProjectID: &graphql.ArgumentConfig{
Type: graphql.String,
DefaultValue: "",
},
FieldPublicID: &graphql.ArgumentConfig{
Type: graphql.String,
DefaultValue: "",
},
FieldName: &graphql.ArgumentConfig{
Type: graphql.NewNonNull(graphql.String),
},
},
Resolve: func(p graphql.ResolveParams) (interface{}, error) {
name, _ := p.Args[FieldName].(string)
projectID, err := getProjectID(p)
if err != nil {
return nil, err
}
info, key, err := service.CreateAPIKey(p.Context, projectID, name)
if err != nil {
return nil, err
}
return createAPIKey{
Key: key.Serialize(),
KeyInfo: info,
}, nil
},
},
// deletes api key
DeleteAPIKeysMutation: &graphql.Field{
Type: graphql.NewList(types.apiKeyInfo),
Args: graphql.FieldConfigArgument{
FieldID: &graphql.ArgumentConfig{
Type: graphql.NewNonNull(graphql.NewList(graphql.String)),
},
},
Resolve: func(p graphql.ResolveParams) (interface{}, error) {
paramKeysID, _ := p.Args[FieldID].([]interface{})
var keyIds []uuid.UUID
var keys []console.APIKeyInfo
for _, id := range paramKeysID {
keyID, err := uuid.FromString(id.(string))
if err != nil {
return nil, err
}
key, err := service.GetAPIKeyInfo(p.Context, keyID)
if err != nil {
return nil, err
}
keyIds = append(keyIds, keyID)
keys = append(keys, *key)
}
err := service.DeleteAPIKeys(p.Context, keyIds)
if err != nil {
return nil, err
}
return keys, nil
},
},
AddPaymentMethodMutation: &graphql.Field{
Type: graphql.Boolean,
Args: graphql.FieldConfigArgument{},
Resolve: func(p graphql.ResolveParams) (interface{}, error) {
return nil, nil
},
},
DeletePaymentMethodMutation: &graphql.Field{
Type: graphql.Boolean,
Args: graphql.FieldConfigArgument{},
Resolve: func(p graphql.ResolveParams) (interface{}, error) {
return nil, nil
},
},
SetDefaultPaymentMethodMutation: &graphql.Field{
Type: graphql.Boolean,
Args: graphql.FieldConfigArgument{},
Resolve: func(p graphql.ResolveParams) (interface{}, error) {
return nil, nil
},
},
},
})
}
func getProjectID(p graphql.ResolveParams) (projectID uuid.UUID, err error) {
inputID, _ := p.Args[FieldID].(string)
inputProjectID, _ := p.Args[FieldProjectID].(string)
inputPublicID, _ := p.Args[FieldPublicID].(string)
if inputID != "" {
projectID, err = uuid.FromString(inputID)
if err != nil {
return uuid.UUID{}, err
}
} else if inputProjectID != "" {
projectID, err = uuid.FromString(inputProjectID)
if err != nil {
return uuid.UUID{}, err
}
} else if inputPublicID != "" {
projectID, err = uuid.FromString(inputPublicID)
if err != nil {
return uuid.UUID{}, err
}
} else {
return uuid.UUID{}, errs.New("Project ID was not provided.")
}
return projectID, nil
}

View File

@ -0,0 +1,511 @@
// Copyright (C) 2019 Storj Labs, Inc.
// See LICENSE for copying information.
package consoleql_test
import (
"context"
"fmt"
"testing"
"time"
"github.com/graphql-go/graphql"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap/zaptest"
"storj.io/common/testcontext"
"storj.io/common/uuid"
"storj.io/storj/private/post"
"storj.io/storj/private/testplanet"
"storj.io/storj/private/testredis"
"storj.io/storj/satellite/accounting"
"storj.io/storj/satellite/accounting/live"
"storj.io/storj/satellite/analytics"
"storj.io/storj/satellite/console"
"storj.io/storj/satellite/console/consoleauth"
"storj.io/storj/satellite/console/consoleweb/consoleql"
"storj.io/storj/satellite/console/restkeys"
"storj.io/storj/satellite/mailservice"
"storj.io/storj/satellite/payments"
"storj.io/storj/satellite/payments/paymentsconfig"
"storj.io/storj/satellite/payments/stripe"
)
// discardSender discard sending of an actual email.
type discardSender struct{}
// SendEmail immediately returns with nil error.
func (*discardSender) SendEmail(ctx context.Context, msg *post.Message) error {
return nil
}
// FromAddress returns empty post.Address.
func (*discardSender) FromAddress() post.Address {
return post.Address{}
}
func TestGraphqlMutation(t *testing.T) {
testplanet.Run(t, testplanet.Config{SatelliteCount: 1}, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) {
sat := planet.Satellites[0]
db := sat.DB
log := zaptest.NewLogger(t)
analyticsService := analytics.NewService(log, analytics.Config{}, "test-satellite")
redis, err := testredis.Mini(ctx)
require.NoError(t, err)
defer ctx.Check(redis.Close)
cache, err := live.OpenCache(ctx, log.Named("cache"), live.Config{StorageBackend: "redis://" + redis.Addr() + "?db=0"})
require.NoError(t, err)
projectLimitCache := accounting.NewProjectLimitCache(db.ProjectAccounting(), 0, 0, 0, accounting.ProjectLimitConfig{CacheCapacity: 100})
projectUsage := accounting.NewService(db.ProjectAccounting(), cache, projectLimitCache, *sat.Metabase.DB, 5*time.Minute, -10*time.Second)
// TODO maybe switch this test to testplanet to avoid defining config and Stripe service
pc := paymentsconfig.Config{
UsagePrice: paymentsconfig.ProjectUsagePrice{
StorageTB: "10",
EgressTB: "45",
Segment: "0.0000022",
},
}
prices, err := pc.UsagePrice.ToModel()
require.NoError(t, err)
priceOverrides, err := pc.UsagePriceOverrides.ToModels()
require.NoError(t, err)
paymentsService, err := stripe.NewService(
log.Named("payments.stripe:service"),
stripe.NewStripeMock(
db.StripeCoinPayments().Customers(),
db.Console().Users(),
),
pc.StripeCoinPayments,
db.StripeCoinPayments(),
db.Wallets(),
db.Billing(),
db.Console().Projects(),
db.Console().Users(),
db.ProjectAccounting(),
prices,
priceOverrides,
pc.PackagePlans.Packages,
pc.BonusRate,
nil,
)
require.NoError(t, err)
service, err := console.NewService(
log.Named("console"),
db.Console(),
restkeys.NewService(db.OIDC().OAuthTokens(), planet.Satellites[0].Config.RESTKeys),
db.ProjectAccounting(),
projectUsage,
sat.API.Buckets.Service,
paymentsService.Accounts(),
// TODO: do we need a payment deposit wallet here?
nil,
db.Billing(),
analyticsService,
consoleauth.NewService(consoleauth.Config{
TokenExpirationTime: 24 * time.Hour,
}, &consoleauth.Hmac{Secret: []byte("my-suppa-secret-key")}),
nil,
"",
"",
console.Config{
PasswordCost: console.TestPasswordCost,
DefaultProjectLimit: 5,
Session: console.SessionConfig{
Duration: time.Hour,
},
},
)
require.NoError(t, err)
mailService, err := mailservice.New(log, &discardSender{}, "testdata")
require.NoError(t, err)
defer ctx.Check(mailService.Close)
rootObject := make(map[string]interface{})
rootObject["origin"] = "http://doesntmatter.com/"
rootObject[consoleql.ActivationPath] = "?activationToken="
rootObject[consoleql.SignInPath] = "login"
rootObject[consoleql.LetUsKnowURL] = "letUsKnowURL"
rootObject[consoleql.ContactInfoURL] = "contactInfoURL"
rootObject[consoleql.TermsAndConditionsURL] = "termsAndConditionsURL"
rootObject[consoleql.SatelliteRegion] = "EU1"
schema, err := consoleql.CreateSchema(log, service, mailService)
require.NoError(t, err)
createUser := console.CreateUser{
FullName: "John Roll",
ShortName: "Roll",
Email: "test@mail.test",
UserAgent: []byte("120bf202-8252-437e-ac12-0e364bee852e"),
Password: "123a123",
SignupPromoCode: "promo1",
}
regToken, err := service.CreateRegToken(ctx, 1)
require.NoError(t, err)
rootUser, err := service.CreateUser(ctx, createUser, regToken.Secret)
require.NoError(t, err)
require.Equal(t, createUser.UserAgent, rootUser.UserAgent)
couponType, err := paymentsService.Accounts().Setup(ctx, rootUser.ID, rootUser.Email, rootUser.SignupPromoCode)
var signupCouponType payments.CouponType = payments.SignupCoupon
require.NoError(t, err)
assert.Equal(t, signupCouponType, couponType)
activationToken, err := service.GenerateActivationToken(ctx, rootUser.ID, rootUser.Email)
require.NoError(t, err)
_, err = service.ActivateAccount(ctx, activationToken)
require.NoError(t, err)
tokenInfo, err := service.Token(ctx, console.AuthUser{Email: createUser.Email, Password: createUser.Password})
require.NoError(t, err)
userCtx, err := service.TokenAuth(ctx, tokenInfo.Token, time.Now())
require.NoError(t, err)
testQuery := func(t *testing.T, query string) (interface{}, error) {
result := graphql.Do(graphql.Params{
Schema: schema,
Context: userCtx,
RequestString: query,
RootObject: rootObject,
})
for _, err := range result.Errors {
if err.OriginalError() != nil {
return nil, err
}
}
require.False(t, result.HasErrors())
return result.Data, nil
}
tokenInfo, err = service.Token(ctx, console.AuthUser{Email: rootUser.Email, Password: createUser.Password})
require.NoError(t, err)
userCtx, err = service.TokenAuth(ctx, tokenInfo.Token, time.Now())
require.NoError(t, err)
var projectIDField string
var projectPublicIDField string
t.Run("Create project mutation", func(t *testing.T) {
projectInfo := console.UpsertProjectInfo{
Name: "Project name",
Description: "desc",
}
query := fmt.Sprintf(
"mutation {createProject(input:{name:\"%s\",description:\"%s\"}){name,description,id,publicId,createdAt}}",
projectInfo.Name,
projectInfo.Description,
)
result, err := testQuery(t, query)
require.NoError(t, err)
data := result.(map[string]interface{})
project := data[consoleql.CreateProjectMutation].(map[string]interface{})
assert.Equal(t, projectInfo.Name, project[consoleql.FieldName])
assert.Equal(t, projectInfo.Description, project[consoleql.FieldDescription])
projectIDField = project[consoleql.FieldID].(string)
projectPublicIDField = project[consoleql.FieldPublicID].(string)
_, err = uuid.FromString(projectPublicIDField)
require.NoError(t, err)
})
projectID, err := uuid.FromString(projectIDField)
require.NoError(t, err)
project, err := service.GetProject(userCtx, projectID)
require.NoError(t, err)
require.Equal(t, rootUser.UserAgent, project.UserAgent)
regTokenUser1, err := service.CreateRegToken(ctx, 1)
require.NoError(t, err)
user1, err := service.CreateUser(userCtx, console.CreateUser{
FullName: "User1",
Email: "u1@mail.test",
Password: "123a123",
}, regTokenUser1.Secret)
require.NoError(t, err)
t.Run("Activation", func(t *testing.T) {
activationToken1, err := service.GenerateActivationToken(
ctx,
user1.ID,
"u1@mail.test",
)
require.NoError(t, err)
_, err = service.ActivateAccount(ctx, activationToken1)
require.NoError(t, err)
user1.Email = "u1@mail.test"
})
regTokenUser2, err := service.CreateRegToken(ctx, 1)
require.NoError(t, err)
user2, err := service.CreateUser(userCtx, console.CreateUser{
FullName: "User1",
Email: "u2@mail.test",
Password: "123a123",
}, regTokenUser2.Secret)
require.NoError(t, err)
t.Run("Activation", func(t *testing.T) {
activationToken2, err := service.GenerateActivationToken(
ctx,
user2.ID,
"u2@mail.test",
)
require.NoError(t, err)
_, err = service.ActivateAccount(ctx, activationToken2)
require.NoError(t, err)
user2.Email = "u2@mail.test"
})
regTokenUser3, err := service.CreateRegToken(ctx, 1)
require.NoError(t, err)
user3, err := service.CreateUser(userCtx, console.CreateUser{
FullName: "User3",
Email: "u3@mail.test",
Password: "123a123",
}, regTokenUser3.Secret)
require.NoError(t, err)
t.Run("Activation", func(t *testing.T) {
activationToken3, err := service.GenerateActivationToken(
ctx,
user3.ID,
"u3@mail.test",
)
require.NoError(t, err)
_, err = service.ActivateAccount(ctx, activationToken3)
require.NoError(t, err)
user3.Email = "u3@mail.test"
})
testAdd := func(query string, expectedMembers int) {
result, err := testQuery(t, query)
require.NoError(t, err)
data := result.(map[string]interface{})
proj := data[consoleql.AddProjectMembersMutation].(map[string]interface{})
members := proj[consoleql.FieldMembersAndInvitations].(map[string]interface{})
projectMembers := members[consoleql.FieldProjectMembers].([]interface{})
assert.Equal(t, project.ID.String(), proj[consoleql.FieldID])
assert.Equal(t, project.PublicID.String(), proj[consoleql.FieldPublicID])
assert.Equal(t, project.Name, proj[consoleql.FieldName])
assert.Equal(t, expectedMembers, len(projectMembers))
}
t.Run("Add project members mutation", func(t *testing.T) {
query := fmt.Sprintf(
"mutation {addProjectMembers(projectID:\"%s\",email:[\"%s\",\"%s\"]){id,publicId,name,membersAndInvitations(cursor: { limit: 50, search: \"\", page: 1, order: 1, orderDirection: 2 }){projectMembers{joinedAt}}}}",
project.ID.String(),
user1.Email,
user2.Email,
)
testAdd(query, 3)
})
t.Run("Add project members mutation with publicId", func(t *testing.T) {
query := fmt.Sprintf(
"mutation {addProjectMembers(publicId:\"%s\",email:[\"%s\"]){id,publicId,name,membersAndInvitations(cursor: { limit: 50, search: \"\", page: 1, order: 1, orderDirection: 2 }){projectMembers{joinedAt}}}}",
project.PublicID.String(),
user3.Email,
)
testAdd(query, 4)
})
t.Run("Fail add project members mutation without ID", func(t *testing.T) {
query := fmt.Sprintf(
"mutation {addProjectMembers(email:[\"%s\",\"%s\"]){id,publicId,name,membersAndInvitations(cursor: { limit: 50, search: \"\", page: 1, order: 1, orderDirection: 2 }){projectMembers{joinedAt}}}}",
user1.Email,
user2.Email,
)
_, err = testQuery(t, query)
require.Error(t, err)
})
t.Run("Delete project members mutation", func(t *testing.T) {
query := fmt.Sprintf(
"mutation {deleteProjectMembers(projectID:\"%s\",email:[\"%s\",\"%s\",\"%s\"]){id,publicId,name,membersAndInvitations(cursor: { limit: 50, search: \"\", page: 1, order: 1, orderDirection: 2 }){projectMembers{user{id}}}}}",
project.ID.String(),
user1.Email,
user2.Email,
user3.Email,
)
result, err := testQuery(t, query)
require.NoError(t, err)
data := result.(map[string]interface{})
proj := data[consoleql.DeleteProjectMembersMutation].(map[string]interface{})
members := proj[consoleql.FieldMembersAndInvitations].(map[string]interface{})
projectMembers := members[consoleql.FieldProjectMembers].([]interface{})
rootMember := projectMembers[0].(map[string]interface{})[consoleql.UserType].(map[string]interface{})
assert.Equal(t, project.ID.String(), proj[consoleql.FieldID])
assert.Equal(t, project.PublicID.String(), proj[consoleql.FieldPublicID])
assert.Equal(t, project.Name, proj[consoleql.FieldName])
assert.Equal(t, 1, len(members))
assert.Equal(t, rootUser.ID.String(), rootMember[consoleql.FieldID])
})
var keyID string
t.Run("Create api key mutation", func(t *testing.T) {
keyName := "key1"
query := fmt.Sprintf(
"mutation {createAPIKey(projectID:\"%s\",name:\"%s\"){key,keyInfo{id,name,projectID}}}",
project.ID.String(),
keyName,
)
result, err := testQuery(t, query)
require.NoError(t, err)
data := result.(map[string]interface{})
createAPIKey := data[consoleql.CreateAPIKeyMutation].(map[string]interface{})
key := createAPIKey[consoleql.FieldKey].(string)
keyInfo := createAPIKey[consoleql.APIKeyInfoType].(map[string]interface{})
assert.NotEqual(t, "", key)
assert.Equal(t, keyName, keyInfo[consoleql.FieldName])
assert.Equal(t, project.ID.String(), keyInfo[consoleql.FieldProjectID])
keyID = keyInfo[consoleql.FieldID].(string)
})
t.Run("Delete api key mutation", func(t *testing.T) {
id, err := uuid.FromString(keyID)
require.NoError(t, err)
info, err := service.GetAPIKeyInfo(userCtx, id)
require.NoError(t, err)
query := fmt.Sprintf(
"mutation {deleteAPIKeys(id:[\"%s\"]){name,projectID}}",
keyID,
)
result, err := testQuery(t, query)
require.NoError(t, err)
data := result.(map[string]interface{})
keyInfoList := data[consoleql.DeleteAPIKeysMutation].([]interface{})
for _, k := range keyInfoList {
keyInfo := k.(map[string]interface{})
assert.Equal(t, info.Name, keyInfo[consoleql.FieldName])
assert.Equal(t, project.ID.String(), keyInfo[consoleql.FieldProjectID])
}
})
const testName = "testName"
const testDescription = "test description"
const StorageLimit = "100"
const BandwidthLimit = "100"
testUpdate := func(query string) {
result, err := testQuery(t, query)
require.NoError(t, err)
data := result.(map[string]interface{})
proj := data[consoleql.UpdateProjectMutation].(map[string]interface{})
assert.Equal(t, project.ID.String(), proj[consoleql.FieldID])
assert.Equal(t, project.PublicID.String(), proj[consoleql.FieldPublicID])
assert.Equal(t, testName, proj[consoleql.FieldName])
assert.Equal(t, testDescription, proj[consoleql.FieldDescription])
}
t.Run("Update project mutation", func(t *testing.T) {
query := fmt.Sprintf(
"mutation {updateProject(id:\"%s\",projectFields:{name:\"%s\",description:\"%s\"},projectLimits:{storageLimit:\"%s\",bandwidthLimit:\"%s\"}){id,publicId,name,description}}",
project.ID.String(),
testName,
testDescription,
StorageLimit,
BandwidthLimit,
)
testUpdate(query)
})
t.Run("Update project mutation with publicId", func(t *testing.T) {
query := fmt.Sprintf(
"mutation {updateProject(publicId:\"%s\",projectFields:{name:\"%s\",description:\"%s\"},projectLimits:{storageLimit:\"%s\",bandwidthLimit:\"%s\"}){id,publicId,name,description}}",
project.PublicID.String(),
testName,
testDescription,
StorageLimit,
BandwidthLimit,
)
testUpdate(query)
})
t.Run("Fail update project mutation without ID", func(t *testing.T) {
query := fmt.Sprintf(
"mutation {updateProject(projectFields:{name:\"%s\",description:\"%s\"},projectLimits:{storageLimit:\"%s\",bandwidthLimit:\"%s\"}){id,publicId,name,description}}",
testName,
testDescription,
StorageLimit,
BandwidthLimit,
)
_, err := testQuery(t, query)
require.Error(t, err)
})
t.Run("Delete project mutation", func(t *testing.T) {
query := fmt.Sprintf(
"mutation {deleteProject(id:\"%s\"){id,name}}",
projectID,
)
result, err := testQuery(t, query)
require.Error(t, err)
require.Nil(t, result)
require.Equal(t, console.ErrUnauthorized.New("not implemented").Error(), err.Error())
})
})
}

View File

@ -0,0 +1,529 @@
// Copyright (C) 2019 Storj Labs, Inc.
// See LICENSE for copying information.
package consoleql
import (
"strconv"
"time"
"github.com/graphql-go/graphql"
"storj.io/common/memory"
"storj.io/storj/satellite/accounting"
"storj.io/storj/satellite/console"
)
const (
// ProjectType is a graphql type name for project.
ProjectType = "project"
// ProjectInputType is a graphql type name for project input.
ProjectInputType = "projectInput"
// ProjectLimitType is a graphql type name for project limit.
ProjectLimitType = "projectLimit"
// ProjectUsageType is a graphql type name for project usage.
ProjectUsageType = "projectUsage"
// ProjectsCursorInputType is a graphql input type name for projects cursor.
ProjectsCursorInputType = "projectsCursor"
// ProjectsPageType is a graphql type name for projects page.
ProjectsPageType = "projectsPage"
// BucketUsageCursorInputType is a graphql input
// type name for bucket usage cursor.
BucketUsageCursorInputType = "bucketUsageCursor"
// BucketUsageType is a graphql type name for bucket usage.
BucketUsageType = "bucketUsage"
// BucketUsagePageType is a graphql type name for bucket usage page.
BucketUsagePageType = "bucketUsagePage"
// ProjectMembersAndInvitationsPageType is a graphql type name for a page of project members and invitations.
ProjectMembersAndInvitationsPageType = "projectMembersAndInvitationsPage"
// ProjectMembersCursorInputType is a graphql type name for project members.
ProjectMembersCursorInputType = "projectMembersCursor"
// APIKeysPageType is a graphql type name for api keys page.
APIKeysPageType = "apiKeysPage"
// APIKeysCursorInputType is a graphql type name for api keys.
APIKeysCursorInputType = "apiKeysCursor"
// FieldPublicID is a field name for "publicId".
FieldPublicID = "publicId"
// FieldOwnerID is a field name for "ownerId".
FieldOwnerID = "ownerId"
// FieldName is a field name for "name".
FieldName = "name"
// FieldBucketName is a field name for "bucket name".
FieldBucketName = "bucketName"
// FieldDescription is a field name for description.
FieldDescription = "description"
// FieldMembersAndInvitations is field name for members and invitations.
FieldMembersAndInvitations = "membersAndInvitations"
// FieldAPIKeys is a field name for api keys.
FieldAPIKeys = "apiKeys"
// FieldUsage is a field name for usage rollup.
FieldUsage = "usage"
// FieldBucketUsages is a field name for bucket usages.
FieldBucketUsages = "bucketUsages"
// FieldStorageLimit is a field name for the storage limit.
FieldStorageLimit = "storageLimit"
// FieldBandwidthLimit is a field name for bandwidth limit.
FieldBandwidthLimit = "bandwidthLimit"
// FieldStorage is a field name for storage total.
FieldStorage = "storage"
// FieldEgress is a field name for egress total.
FieldEgress = "egress"
// FieldSegmentCount is a field name for segments count.
FieldSegmentCount = "segmentCount"
// FieldObjectCount is a field name for objects count.
FieldObjectCount = "objectCount"
// FieldPageCount is a field name for total page count.
FieldPageCount = "pageCount"
// FieldCurrentPage is a field name for current page number.
FieldCurrentPage = "currentPage"
// FieldTotalCount is a field name for bucket usage count total.
FieldTotalCount = "totalCount"
// FieldMemberCount is a field name for number of project members.
FieldMemberCount = "memberCount"
// FieldProjects is a field name for projects.
FieldProjects = "projects"
// FieldProjectMembers is a field name for project members.
FieldProjectMembers = "projectMembers"
// FieldProjectInvitations is a field name for project member invitations.
FieldProjectInvitations = "projectInvitations"
// CursorArg is an argument name for cursor.
CursorArg = "cursor"
// PageArg ia an argument name for page number.
PageArg = "page"
// LimitArg is argument name for limit.
LimitArg = "limit"
// OffsetArg is argument name for offset.
OffsetArg = "offset"
// SearchArg is argument name for search.
SearchArg = "search"
// OrderArg is argument name for order.
OrderArg = "order"
// OrderDirectionArg is argument name for order direction.
OrderDirectionArg = "orderDirection"
// SinceArg marks start of the period.
SinceArg = "since"
// BeforeArg marks end of the period.
BeforeArg = "before"
)
// graphqlProject creates *graphql.Object type representation of satellite.ProjectInfo.
func graphqlProject(service *console.Service, types *TypeCreator) *graphql.Object {
return graphql.NewObject(graphql.ObjectConfig{
Name: ProjectType,
Fields: graphql.Fields{
FieldID: &graphql.Field{
Type: graphql.String,
},
FieldPublicID: &graphql.Field{
Type: graphql.String,
},
FieldName: &graphql.Field{
Type: graphql.String,
},
FieldOwnerID: &graphql.Field{
Type: graphql.String,
},
FieldDescription: &graphql.Field{
Type: graphql.String,
},
FieldCreatedAt: &graphql.Field{
Type: graphql.DateTime,
},
FieldMemberCount: &graphql.Field{
Type: graphql.Int,
},
FieldMembersAndInvitations: &graphql.Field{
Type: types.projectMembersAndInvitationsPage,
Args: graphql.FieldConfigArgument{
CursorArg: &graphql.ArgumentConfig{
Type: graphql.NewNonNull(types.projectMembersCursor),
},
},
Resolve: func(p graphql.ResolveParams) (interface{}, error) {
project, _ := p.Source.(*console.Project)
_, err := console.GetUser(p.Context)
if err != nil {
return nil, err
}
cursor := cursorArgsToProjectMembersCursor(p.Args[CursorArg].(map[string]interface{}))
page, err := service.GetProjectMembersAndInvitations(p.Context, project.ID, cursor)
if err != nil {
return nil, err
}
var users []projectMember
for _, member := range page.ProjectMembers {
user, err := service.GetUser(p.Context, member.MemberID)
if err != nil {
return nil, err
}
users = append(users, projectMember{
User: user,
JoinedAt: member.CreatedAt,
})
}
var invites []projectInvitation
for _, invite := range page.ProjectInvitations {
invites = append(invites, projectInvitation{
Email: invite.Email,
CreatedAt: invite.CreatedAt,
Expired: service.IsProjectInvitationExpired(&invite),
})
}
projectMembersPage := projectMembersPage{
ProjectMembers: users,
ProjectInvitations: invites,
TotalCount: page.TotalCount,
Offset: page.Offset,
Limit: page.Limit,
Order: int(page.Order),
OrderDirection: int(page.OrderDirection),
Search: page.Search,
CurrentPage: page.CurrentPage,
PageCount: page.PageCount,
}
return projectMembersPage, nil
},
},
FieldAPIKeys: &graphql.Field{
Type: types.apiKeyPage,
Args: graphql.FieldConfigArgument{
CursorArg: &graphql.ArgumentConfig{
Type: graphql.NewNonNull(types.apiKeysCursor),
},
},
Resolve: func(p graphql.ResolveParams) (interface{}, error) {
project, _ := p.Source.(*console.Project)
cursor := cursorArgsToAPIKeysCursor(p.Args[CursorArg].(map[string]interface{}))
page, err := service.GetAPIKeys(p.Context, project.ID, cursor)
if err != nil {
return nil, err
}
apiKeysPage := apiKeysPage{
APIKeys: page.APIKeys,
TotalCount: page.TotalCount,
Offset: page.Offset,
Limit: page.Limit,
Order: int(page.Order),
OrderDirection: int(page.OrderDirection),
Search: page.Search,
CurrentPage: page.CurrentPage,
PageCount: page.PageCount,
}
return apiKeysPage, err
},
},
FieldUsage: &graphql.Field{
Type: types.projectUsage,
Args: graphql.FieldConfigArgument{
SinceArg: &graphql.ArgumentConfig{
Type: graphql.NewNonNull(graphql.DateTime),
},
BeforeArg: &graphql.ArgumentConfig{
Type: graphql.NewNonNull(graphql.DateTime),
},
},
Resolve: func(p graphql.ResolveParams) (interface{}, error) {
project, _ := p.Source.(*console.Project)
since := p.Args[SinceArg].(time.Time)
before := p.Args[BeforeArg].(time.Time)
usage, err := service.GetProjectUsage(p.Context, project.ID, since, before)
if err != nil {
return nil, err
}
return usage, nil
},
},
FieldBucketUsages: &graphql.Field{
Type: types.bucketUsagePage,
Args: graphql.FieldConfigArgument{
BeforeArg: &graphql.ArgumentConfig{
Type: graphql.NewNonNull(graphql.DateTime),
},
CursorArg: &graphql.ArgumentConfig{
Type: graphql.NewNonNull(types.bucketUsageCursor),
},
},
Resolve: func(p graphql.ResolveParams) (interface{}, error) {
project, _ := p.Source.(*console.Project)
before := p.Args[BeforeArg].(time.Time)
cursor := fromMapBucketUsageCursor(p.Args[CursorArg].(map[string]interface{}))
page, err := service.GetBucketTotals(p.Context, project.ID, cursor, before)
if err != nil {
return nil, err
}
return page, nil
},
},
},
})
}
// graphqlProjectInput creates graphql.InputObject type needed to create/update satellite.Project.
func graphqlProjectInput() *graphql.InputObject {
return graphql.NewInputObject(graphql.InputObjectConfig{
Name: ProjectInputType,
Fields: graphql.InputObjectConfigFieldMap{
FieldName: &graphql.InputObjectFieldConfig{
Type: graphql.String,
},
FieldDescription: &graphql.InputObjectFieldConfig{
Type: graphql.String,
},
},
})
}
// graphqlProjectLimit creates graphql.InputObject type needed to create/update satellite.Project.
func graphqlProjectLimit() *graphql.InputObject {
return graphql.NewInputObject(graphql.InputObjectConfig{
Name: ProjectLimitType,
Fields: graphql.InputObjectConfigFieldMap{
FieldStorageLimit: &graphql.InputObjectFieldConfig{
Type: graphql.String,
},
FieldBandwidthLimit: &graphql.InputObjectFieldConfig{
Type: graphql.String,
},
},
})
}
// graphqlBucketUsageCursor creates bucket usage cursor graphql input type.
func graphqlProjectsCursor() *graphql.InputObject {
return graphql.NewInputObject(graphql.InputObjectConfig{
Name: ProjectsCursorInputType,
Fields: graphql.InputObjectConfigFieldMap{
LimitArg: &graphql.InputObjectFieldConfig{
Type: graphql.NewNonNull(graphql.Int),
},
PageArg: &graphql.InputObjectFieldConfig{
Type: graphql.NewNonNull(graphql.Int),
},
},
})
}
// graphqlBucketUsageCursor creates bucket usage cursor graphql input type.
func graphqlBucketUsageCursor() *graphql.InputObject {
return graphql.NewInputObject(graphql.InputObjectConfig{
Name: BucketUsageCursorInputType,
Fields: graphql.InputObjectConfigFieldMap{
SearchArg: &graphql.InputObjectFieldConfig{
Type: graphql.NewNonNull(graphql.String),
},
LimitArg: &graphql.InputObjectFieldConfig{
Type: graphql.NewNonNull(graphql.Int),
},
PageArg: &graphql.InputObjectFieldConfig{
Type: graphql.NewNonNull(graphql.Int),
},
},
})
}
// graphqlBucketUsage creates bucket usage grapqhl type.
func graphqlBucketUsage() *graphql.Object {
return graphql.NewObject(graphql.ObjectConfig{
Name: BucketUsageType,
Fields: graphql.Fields{
FieldBucketName: &graphql.Field{
Type: graphql.String,
},
FieldStorage: &graphql.Field{
Type: graphql.Float,
},
FieldEgress: &graphql.Field{
Type: graphql.Float,
},
FieldObjectCount: &graphql.Field{
Type: graphql.Float,
},
FieldSegmentCount: &graphql.Field{
Type: graphql.Float,
},
SinceArg: &graphql.Field{
Type: graphql.DateTime,
},
BeforeArg: &graphql.Field{
Type: graphql.DateTime,
},
},
})
}
// graphqlProjectsPage creates a projects page graphql object.
func graphqlProjectsPage(types *TypeCreator) *graphql.Object {
return graphql.NewObject(graphql.ObjectConfig{
Name: ProjectsPageType,
Fields: graphql.Fields{
FieldProjects: &graphql.Field{
Type: graphql.NewList(types.project),
},
LimitArg: &graphql.Field{
Type: graphql.Int,
},
OffsetArg: &graphql.Field{
Type: graphql.Int,
},
FieldPageCount: &graphql.Field{
Type: graphql.Int,
},
FieldCurrentPage: &graphql.Field{
Type: graphql.Int,
},
FieldTotalCount: &graphql.Field{
Type: graphql.Int,
},
},
})
}
// graphqlBucketUsagePage creates bucket usage page graphql object.
func graphqlBucketUsagePage(types *TypeCreator) *graphql.Object {
return graphql.NewObject(graphql.ObjectConfig{
Name: BucketUsagePageType,
Fields: graphql.Fields{
FieldBucketUsages: &graphql.Field{
Type: graphql.NewList(types.bucketUsage),
},
SearchArg: &graphql.Field{
Type: graphql.String,
},
LimitArg: &graphql.Field{
Type: graphql.Int,
},
OffsetArg: &graphql.Field{
Type: graphql.Int,
},
FieldPageCount: &graphql.Field{
Type: graphql.Int,
},
FieldCurrentPage: &graphql.Field{
Type: graphql.Int,
},
FieldTotalCount: &graphql.Field{
Type: graphql.Int,
},
},
})
}
// graphqlProjectUsage creates project usage graphql type.
func graphqlProjectUsage() *graphql.Object {
return graphql.NewObject(graphql.ObjectConfig{
Name: ProjectUsageType,
Fields: graphql.Fields{
FieldStorage: &graphql.Field{
Type: graphql.Float,
},
FieldEgress: &graphql.Field{
Type: graphql.Float,
},
FieldObjectCount: &graphql.Field{
Type: graphql.Float,
},
FieldSegmentCount: &graphql.Field{
Type: graphql.Float,
},
SinceArg: &graphql.Field{
Type: graphql.DateTime,
},
BeforeArg: &graphql.Field{
Type: graphql.DateTime,
},
},
})
}
// fromMapProjectInfo creates console.UpsertProjectInfo from input args.
func fromMapProjectInfo(args map[string]interface{}) (project console.UpsertProjectInfo) {
project.Name, _ = args[FieldName].(string)
project.Description, _ = args[FieldDescription].(string)
return
}
// fromMapProjectInfoProjectLimits creates console.UpsertProjectInfo from input args.
func fromMapProjectInfoProjectLimits(projectInfo, projectLimits map[string]interface{}) (project console.UpsertProjectInfo, err error) {
project.Name, _ = projectInfo[FieldName].(string)
project.Description, _ = projectInfo[FieldDescription].(string)
storageLimit, err := strconv.Atoi(projectLimits[FieldStorageLimit].(string))
if err != nil {
return project, err
}
project.StorageLimit = memory.Size(storageLimit)
bandwidthLimit, err := strconv.Atoi(projectLimits[FieldBandwidthLimit].(string))
if err != nil {
return project, err
}
project.BandwidthLimit = memory.Size(bandwidthLimit)
return
}
// fromMapProjectsCursor creates console.ProjectsCursor from input args.
func fromMapProjectsCursor(args map[string]interface{}) (cursor console.ProjectsCursor) {
cursor.Limit = args[LimitArg].(int)
cursor.Page = args[PageArg].(int)
return
}
// fromMapBucketUsageCursor creates accounting.BucketUsageCursor from input args.
func fromMapBucketUsageCursor(args map[string]interface{}) (cursor accounting.BucketUsageCursor) {
limit, _ := args[LimitArg].(int)
page, _ := args[PageArg].(int)
cursor.Limit = uint(limit)
cursor.Page = uint(page)
cursor.Search, _ = args[SearchArg].(string)
return
}
func cursorArgsToProjectMembersCursor(args map[string]interface{}) console.ProjectMembersCursor {
limit, _ := args[LimitArg].(int)
page, _ := args[PageArg].(int)
order, _ := args[OrderArg].(int)
orderDirection, _ := args[OrderDirectionArg].(int)
var cursor console.ProjectMembersCursor
cursor.Limit = uint(limit)
cursor.Page = uint(page)
cursor.Order = console.ProjectMemberOrder(order)
cursor.OrderDirection = console.OrderDirection(orderDirection)
cursor.Search, _ = args[SearchArg].(string)
return cursor
}
func cursorArgsToAPIKeysCursor(args map[string]interface{}) console.APIKeyCursor {
limit, _ := args[LimitArg].(int)
page, _ := args[PageArg].(int)
order, _ := args[OrderArg].(int)
orderDirection, _ := args[OrderDirectionArg].(int)
var cursor console.APIKeyCursor
cursor.Limit = uint(limit)
cursor.Page = uint(page)
cursor.Order = console.APIKeyOrder(order)
cursor.OrderDirection = console.OrderDirection(orderDirection)
cursor.Search, _ = args[SearchArg].(string)
return cursor
}

View File

@ -0,0 +1,150 @@
// Copyright (C) 2019 Storj Labs, Inc.
// See LICENSE for copying information.
package consoleql
import (
"time"
"github.com/graphql-go/graphql"
"storj.io/storj/satellite/console"
)
const (
// ProjectMemberType is a graphql type name for project member.
ProjectMemberType = "projectMember"
// ProjectInvitationType is a graphql type name for project member invitation.
ProjectInvitationType = "projectInvitation"
// FieldJoinedAt is a field name for joined at timestamp.
FieldJoinedAt = "joinedAt"
// FieldExpired is a field name for expiration status.
FieldExpired = "expired"
)
// graphqlProjectMember creates projectMember type.
func graphqlProjectMember(service *console.Service, types *TypeCreator) *graphql.Object {
return graphql.NewObject(graphql.ObjectConfig{
Name: ProjectMemberType,
Fields: graphql.Fields{
UserType: &graphql.Field{
Type: types.user,
Resolve: func(p graphql.ResolveParams) (interface{}, error) {
member, _ := p.Source.(projectMember)
// company sub query expects pointer
return member.User, nil
},
},
FieldJoinedAt: &graphql.Field{
Type: graphql.DateTime,
},
},
})
}
// graphqlProjectInvitation creates projectInvitation type.
func graphqlProjectInvitation() *graphql.Object {
return graphql.NewObject(graphql.ObjectConfig{
Name: ProjectInvitationType,
Fields: graphql.Fields{
FieldEmail: &graphql.Field{
Type: graphql.String,
},
FieldCreatedAt: &graphql.Field{
Type: graphql.DateTime,
},
FieldExpired: &graphql.Field{
Type: graphql.Boolean,
},
},
})
}
func graphqlProjectMembersCursor() *graphql.InputObject {
return graphql.NewInputObject(graphql.InputObjectConfig{
Name: ProjectMembersCursorInputType,
Fields: graphql.InputObjectConfigFieldMap{
SearchArg: &graphql.InputObjectFieldConfig{
Type: graphql.NewNonNull(graphql.String),
},
LimitArg: &graphql.InputObjectFieldConfig{
Type: graphql.NewNonNull(graphql.Int),
},
PageArg: &graphql.InputObjectFieldConfig{
Type: graphql.NewNonNull(graphql.Int),
},
OrderArg: &graphql.InputObjectFieldConfig{
Type: graphql.NewNonNull(graphql.Int),
},
OrderDirectionArg: &graphql.InputObjectFieldConfig{
Type: graphql.NewNonNull(graphql.Int),
},
},
})
}
func graphqlProjectMembersAndInvitationsPage(types *TypeCreator) *graphql.Object {
return graphql.NewObject(graphql.ObjectConfig{
Name: ProjectMembersAndInvitationsPageType,
Fields: graphql.Fields{
FieldProjectMembers: &graphql.Field{
Type: graphql.NewList(types.projectMember),
},
FieldProjectInvitations: &graphql.Field{
Type: graphql.NewList(types.projectInvitation),
},
SearchArg: &graphql.Field{
Type: graphql.String,
},
LimitArg: &graphql.Field{
Type: graphql.Int,
},
OrderArg: &graphql.Field{
Type: graphql.Int,
},
OrderDirectionArg: &graphql.Field{
Type: graphql.Int,
},
OffsetArg: &graphql.Field{
Type: graphql.Int,
},
FieldPageCount: &graphql.Field{
Type: graphql.Int,
},
FieldCurrentPage: &graphql.Field{
Type: graphql.Int,
},
FieldTotalCount: &graphql.Field{
Type: graphql.Int,
},
},
})
}
// projectMember encapsulates User and joinedAt.
type projectMember struct {
User *console.User
JoinedAt time.Time
}
// projectInvitation encapsulates a console.ProjectInvitation and its expiration status.
type projectInvitation struct {
Email string
CreatedAt time.Time
Expired bool
}
type projectMembersPage struct {
ProjectMembers []projectMember
ProjectInvitations []projectInvitation
Search string
Limit uint
Order int
OrderDirection int
Offset uint64
PageCount uint
CurrentPage uint
TotalCount uint64
}

View File

@ -0,0 +1,82 @@
// Copyright (C) 2019 Storj Labs, Inc.
// See LICENSE for copying information.
package consoleql
import (
"github.com/graphql-go/graphql"
"storj.io/storj/satellite/console"
"storj.io/storj/satellite/mailservice"
)
const (
// Query is immutable graphql request.
Query = "query"
// ProjectQuery is a query name for project.
ProjectQuery = "project"
// OwnedProjectsQuery is a query name for projects owned by an account.
OwnedProjectsQuery = "ownedProjects"
// MyProjectsQuery is a query name for projects related to account.
MyProjectsQuery = "myProjects"
)
// rootQuery creates query for graphql populated by AccountsClient.
func rootQuery(service *console.Service, mailService *mailservice.Service, types *TypeCreator) *graphql.Object {
return graphql.NewObject(graphql.ObjectConfig{
Name: Query,
Fields: graphql.Fields{
ProjectQuery: &graphql.Field{
Type: types.project,
Args: graphql.FieldConfigArgument{
FieldID: &graphql.ArgumentConfig{
Type: graphql.String,
DefaultValue: "",
},
FieldPublicID: &graphql.ArgumentConfig{
Type: graphql.String,
DefaultValue: "",
},
},
Resolve: func(p graphql.ResolveParams) (interface{}, error) {
projectID, err := getProjectID(p)
if err != nil {
return nil, err
}
project, err := service.GetProject(p.Context, projectID)
if err != nil {
return nil, err
}
return project, nil
},
},
OwnedProjectsQuery: &graphql.Field{
Type: types.projectsPage,
Args: graphql.FieldConfigArgument{
CursorArg: &graphql.ArgumentConfig{
Type: graphql.NewNonNull(types.projectsCursor),
},
},
Resolve: func(p graphql.ResolveParams) (interface{}, error) {
cursor := fromMapProjectsCursor(p.Args[CursorArg].(map[string]interface{}))
page, err := service.GetUsersOwnedProjectsPage(p.Context, cursor)
return page, err
},
},
MyProjectsQuery: &graphql.Field{
Type: graphql.NewList(types.project),
Resolve: func(p graphql.ResolveParams) (interface{}, error) {
projects, err := service.GetUsersProjects(p.Context)
if err != nil {
return nil, err
}
return projects, nil
},
},
},
})
}

View File

@ -0,0 +1,501 @@
// Copyright (C) 2019 Storj Labs, Inc.
// See LICENSE for copying information.
package consoleql_test
import (
"fmt"
"testing"
"time"
"github.com/graphql-go/graphql"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap/zaptest"
"storj.io/common/testcontext"
"storj.io/storj/private/testplanet"
"storj.io/storj/private/testredis"
"storj.io/storj/satellite/accounting"
"storj.io/storj/satellite/accounting/live"
"storj.io/storj/satellite/analytics"
"storj.io/storj/satellite/console"
"storj.io/storj/satellite/console/consoleauth"
"storj.io/storj/satellite/console/consoleweb/consoleql"
"storj.io/storj/satellite/console/restkeys"
"storj.io/storj/satellite/mailservice"
"storj.io/storj/satellite/payments"
"storj.io/storj/satellite/payments/paymentsconfig"
"storj.io/storj/satellite/payments/stripe"
)
func TestGraphqlQuery(t *testing.T) {
testplanet.Run(t, testplanet.Config{SatelliteCount: 1}, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) {
sat := planet.Satellites[0]
db := sat.DB
log := zaptest.NewLogger(t)
analyticsService := analytics.NewService(log, analytics.Config{}, "test-satellite")
redis, err := testredis.Mini(ctx)
require.NoError(t, err)
defer ctx.Check(redis.Close)
cache, err := live.OpenCache(ctx, log.Named("cache"), live.Config{StorageBackend: "redis://" + redis.Addr() + "?db=0"})
require.NoError(t, err)
projectLimitCache := accounting.NewProjectLimitCache(db.ProjectAccounting(), 0, 0, 0, accounting.ProjectLimitConfig{CacheCapacity: 100})
projectUsage := accounting.NewService(db.ProjectAccounting(), cache, projectLimitCache, *sat.Metabase.DB, 5*time.Minute, -10*time.Second)
// TODO maybe switch this test to testplanet to avoid defining config and Stripe service
pc := paymentsconfig.Config{
UsagePrice: paymentsconfig.ProjectUsagePrice{
StorageTB: "10",
EgressTB: "45",
Segment: "0.0000022",
},
}
prices, err := pc.UsagePrice.ToModel()
require.NoError(t, err)
priceOverrides, err := pc.UsagePriceOverrides.ToModels()
require.NoError(t, err)
paymentsService, err := stripe.NewService(
log.Named("payments.stripe:service"),
stripe.NewStripeMock(
db.StripeCoinPayments().Customers(),
db.Console().Users(),
),
pc.StripeCoinPayments,
db.StripeCoinPayments(),
db.Wallets(),
db.Billing(),
db.Console().Projects(),
db.Console().Users(),
db.ProjectAccounting(),
prices,
priceOverrides,
pc.PackagePlans.Packages,
pc.BonusRate,
nil,
)
require.NoError(t, err)
service, err := console.NewService(
log.Named("console"),
db.Console(),
restkeys.NewService(db.OIDC().OAuthTokens(), planet.Satellites[0].Config.RESTKeys),
db.ProjectAccounting(),
projectUsage,
sat.API.Buckets.Service,
paymentsService.Accounts(),
// TODO: do we need a payment deposit wallet here?
nil,
db.Billing(),
analyticsService,
consoleauth.NewService(consoleauth.Config{
TokenExpirationTime: 24 * time.Hour,
}, &consoleauth.Hmac{Secret: []byte("my-suppa-secret-key")}),
nil,
"",
"",
console.Config{
PasswordCost: console.TestPasswordCost,
DefaultProjectLimit: 5,
Session: console.SessionConfig{
Duration: time.Hour,
},
},
)
require.NoError(t, err)
mailService, err := mailservice.New(log, &discardSender{}, "testdata")
require.NoError(t, err)
defer ctx.Check(mailService.Close)
rootObject := make(map[string]interface{})
rootObject["origin"] = "http://doesntmatter.com/"
rootObject[consoleql.ActivationPath] = "?activationToken="
rootObject[consoleql.LetUsKnowURL] = "letUsKnowURL"
rootObject[consoleql.ContactInfoURL] = "contactInfoURL"
rootObject[consoleql.TermsAndConditionsURL] = "termsAndConditionsURL"
creator := consoleql.TypeCreator{}
err = creator.Create(log, service, mailService)
require.NoError(t, err)
schema, err := graphql.NewSchema(graphql.SchemaConfig{
Query: creator.RootQuery(),
Mutation: creator.RootMutation(),
})
require.NoError(t, err)
createUser := console.CreateUser{
FullName: "John",
ShortName: "",
Email: "mtest@mail.test",
Password: "123a123",
SignupPromoCode: "promo1",
}
regToken, err := service.CreateRegToken(ctx, 2)
require.NoError(t, err)
rootUser, err := service.CreateUser(ctx, createUser, regToken.Secret)
require.NoError(t, err)
couponType, err := paymentsService.Accounts().Setup(ctx, rootUser.ID, rootUser.Email, rootUser.SignupPromoCode)
var signupCouponType payments.CouponType = payments.SignupCoupon
require.NoError(t, err)
assert.Equal(t, signupCouponType, couponType)
t.Run("Activation", func(t *testing.T) {
activationToken, err := service.GenerateActivationToken(
ctx,
rootUser.ID,
"mtest@mail.test",
)
require.NoError(t, err)
_, err = service.ActivateAccount(ctx, activationToken)
require.NoError(t, err)
rootUser.Email = "mtest@mail.test"
})
tokenInfo, err := service.Token(ctx, console.AuthUser{Email: createUser.Email, Password: createUser.Password})
require.NoError(t, err)
userCtx, err := service.TokenAuth(ctx, tokenInfo.Token, time.Now())
require.NoError(t, err)
testQuery := func(t *testing.T, query string) interface{} {
result := graphql.Do(graphql.Params{
Schema: schema,
Context: userCtx,
RequestString: query,
RootObject: rootObject,
})
for _, err := range result.Errors {
assert.NoError(t, err)
}
require.False(t, result.HasErrors())
return result.Data
}
createdProject, err := service.CreateProject(userCtx, console.UpsertProjectInfo{
Name: "TestProject",
})
require.NoError(t, err)
// "query {project(id:\"%s\"){id,name,members(offset:0, limit:50){user{fullName,shortName,email}},apiKeys{name,id,createdAt,projectID}}}"
t.Run("Project query base info", func(t *testing.T) {
query := fmt.Sprintf(
"query {project(id:\"%s\"){id,name,publicId,description,createdAt}}",
createdProject.ID.String(),
)
result := testQuery(t, query)
data := result.(map[string]interface{})
project := data[consoleql.ProjectQuery].(map[string]interface{})
assert.Equal(t, createdProject.ID.String(), project[consoleql.FieldID])
assert.Equal(t, createdProject.PublicID.String(), project[consoleql.FieldPublicID])
assert.Equal(t, createdProject.Name, project[consoleql.FieldName])
assert.Equal(t, createdProject.Description, project[consoleql.FieldDescription])
createdAt := time.Time{}
err := createdAt.UnmarshalText([]byte(project[consoleql.FieldCreatedAt].(string)))
assert.NoError(t, err)
assert.True(t, createdProject.CreatedAt.Equal(createdAt))
// test getting by publicId
query = fmt.Sprintf(
"query {project(publicId:\"%s\"){id,name,publicId,description,createdAt}}",
createdProject.PublicID.String(),
)
result = testQuery(t, query)
data = result.(map[string]interface{})
project = data[consoleql.ProjectQuery].(map[string]interface{})
assert.Equal(t, createdProject.ID.String(), project[consoleql.FieldID])
assert.Equal(t, createdProject.PublicID.String(), project[consoleql.FieldPublicID])
})
regTokenUser1, err := service.CreateRegToken(ctx, 2)
require.NoError(t, err)
user1, err := service.CreateUser(userCtx, console.CreateUser{
FullName: "Mickey Last",
ShortName: "Last",
Password: "123a123",
Email: "muu1@mail.test",
}, regTokenUser1.Secret)
require.NoError(t, err)
t.Run("Activation", func(t *testing.T) {
activationToken1, err := service.GenerateActivationToken(
ctx,
user1.ID,
"muu1@mail.test",
)
require.NoError(t, err)
_, err = service.ActivateAccount(ctx, activationToken1)
require.NoError(t, err)
user1.Email = "muu1@mail.test"
})
regTokenUser2, err := service.CreateRegToken(ctx, 2)
require.NoError(t, err)
user2, err := service.CreateUser(userCtx, console.CreateUser{
FullName: "Dubas Name",
ShortName: "Name",
Email: "muu2@mail.test",
Password: "123a123",
}, regTokenUser2.Secret)
require.NoError(t, err)
t.Run("Activation", func(t *testing.T) {
activationToken2, err := service.GenerateActivationToken(
ctx,
user2.ID,
"muu2@mail.test",
)
require.NoError(t, err)
_, err = service.ActivateAccount(ctx, activationToken2)
require.NoError(t, err)
user2.Email = "muu2@mail.test"
})
users, err := service.AddProjectMembers(userCtx, createdProject.ID, []string{
user1.Email,
user2.Email,
})
require.NoError(t, err)
assert.Equal(t, 2, len(users))
t.Run("Project query team members", func(t *testing.T) {
query := fmt.Sprintf(
"query {project(id: \"%s\") {membersAndInvitations( cursor: { limit: %d, search: \"%s\", page: %d, order: %d, orderDirection: %d } ) { projectMembers{ user { id, fullName, shortName, email, createdAt }, joinedAt }, search, limit, order, offset, pageCount, currentPage, totalCount } } }",
createdProject.ID.String(),
5,
"",
1,
1,
2)
result := testQuery(t, query)
data := result.(map[string]interface{})
project := data[consoleql.ProjectQuery].(map[string]interface{})
members := project[consoleql.FieldMembersAndInvitations].(map[string]interface{})
projectMembers := members[consoleql.FieldProjectMembers].([]interface{})
assert.Equal(t, 3, len(projectMembers))
testUser := func(t *testing.T, actual map[string]interface{}, expected *console.User) {
assert.Equal(t, expected.Email, actual[consoleql.FieldEmail])
assert.Equal(t, expected.FullName, actual[consoleql.FieldFullName])
assert.Equal(t, expected.ShortName, actual[consoleql.FieldShortName])
createdAt := time.Time{}
err := createdAt.UnmarshalText([]byte(actual[consoleql.FieldCreatedAt].(string)))
assert.NoError(t, err)
assert.True(t, expected.CreatedAt.Equal(createdAt))
}
var foundRoot, foundU1, foundU2 bool
for _, entry := range projectMembers {
member := entry.(map[string]interface{})
user := member[consoleql.UserType].(map[string]interface{})
id := user[consoleql.FieldID].(string)
switch id {
case rootUser.ID.String():
foundRoot = true
testUser(t, user, rootUser)
case user1.ID.String():
foundU1 = true
testUser(t, user, user1)
case user2.ID.String():
foundU2 = true
testUser(t, user, user2)
}
}
assert.True(t, foundRoot)
assert.True(t, foundU1)
assert.True(t, foundU2)
})
keyInfo1, _, err := service.CreateAPIKey(userCtx, createdProject.ID, "key1")
require.NoError(t, err)
keyInfo2, _, err := service.CreateAPIKey(userCtx, createdProject.ID, "key2")
require.NoError(t, err)
t.Run("Project query api keys", func(t *testing.T) {
query := fmt.Sprintf(
"query {project(id: \"%s\") {apiKeys( cursor: { limit: %d, search: \"%s\", page: %d, order: %d, orderDirection: %d } ) { apiKeys { id, name, createdAt, projectID }, search, limit, order, offset, pageCount, currentPage, totalCount } } }",
createdProject.ID.String(),
5,
"",
1,
1,
2)
result := testQuery(t, query)
data := result.(map[string]interface{})
project := data[consoleql.ProjectQuery].(map[string]interface{})
keys := project[consoleql.FieldAPIKeys].(map[string]interface{})
apiKeys := keys[consoleql.FieldAPIKeys].([]interface{})
assert.Equal(t, 2, len(apiKeys))
testAPIKey := func(t *testing.T, actual map[string]interface{}, expected *console.APIKeyInfo) {
assert.Equal(t, expected.Name, actual[consoleql.FieldName])
assert.Equal(t, expected.ProjectID.String(), actual[consoleql.FieldProjectID])
createdAt := time.Time{}
err := createdAt.UnmarshalText([]byte(actual[consoleql.FieldCreatedAt].(string)))
assert.NoError(t, err)
assert.True(t, expected.CreatedAt.Equal(createdAt))
}
var foundKey1, foundKey2 bool
for _, entry := range apiKeys {
key := entry.(map[string]interface{})
id := key[consoleql.FieldID].(string)
switch id {
case keyInfo1.ID.String():
foundKey1 = true
testAPIKey(t, key, keyInfo1)
case keyInfo2.ID.String():
foundKey2 = true
testAPIKey(t, key, keyInfo2)
}
}
assert.True(t, foundKey1)
assert.True(t, foundKey2)
})
project2, err := service.CreateProject(userCtx, console.UpsertProjectInfo{
Name: "Project2",
Description: "Test desc",
})
require.NoError(t, err)
t.Run("MyProjects query", func(t *testing.T) {
query := "query {myProjects{id,publicId,name,description,createdAt}}"
result := testQuery(t, query)
data := result.(map[string]interface{})
projectsList := data[consoleql.MyProjectsQuery].([]interface{})
assert.Equal(t, 2, len(projectsList))
testProject := func(t *testing.T, actual map[string]interface{}, expected *console.Project) {
assert.Equal(t, expected.Name, actual[consoleql.FieldName])
assert.Equal(t, expected.PublicID.String(), actual[consoleql.FieldPublicID])
assert.Equal(t, expected.Description, actual[consoleql.FieldDescription])
createdAt := time.Time{}
err := createdAt.UnmarshalText([]byte(actual[consoleql.FieldCreatedAt].(string)))
assert.NoError(t, err)
assert.True(t, expected.CreatedAt.Equal(createdAt))
}
var foundProj1, foundProj2 bool
for _, entry := range projectsList {
project := entry.(map[string]interface{})
id := project[consoleql.FieldID].(string)
switch id {
case createdProject.ID.String():
foundProj1 = true
testProject(t, project, createdProject)
case project2.ID.String():
foundProj2 = true
testProject(t, project, project2)
}
}
assert.True(t, foundProj1)
assert.True(t, foundProj2)
})
t.Run("OwnedProjects query", func(t *testing.T) {
query := fmt.Sprintf(
"query {ownedProjects( cursor: { limit: %d, page: %d } ) {projects{id, publicId, name, ownerId, description, createdAt, memberCount}, limit, offset, pageCount, currentPage, totalCount } }",
5,
1,
)
result := testQuery(t, query)
data := result.(map[string]interface{})
projectsPage := data[consoleql.OwnedProjectsQuery].(map[string]interface{})
projectsList := projectsPage[consoleql.FieldProjects].([]interface{})
assert.Len(t, projectsList, 2)
assert.EqualValues(t, 1, projectsPage[consoleql.FieldCurrentPage])
assert.EqualValues(t, 0, projectsPage[consoleql.OffsetArg])
assert.EqualValues(t, 5, projectsPage[consoleql.LimitArg])
assert.EqualValues(t, 1, projectsPage[consoleql.FieldPageCount])
assert.EqualValues(t, 2, projectsPage[consoleql.FieldTotalCount])
testProject := func(t *testing.T, actual map[string]interface{}, expected *console.Project, expectedNumMembers int) {
assert.Equal(t, expected.Name, actual[consoleql.FieldName])
assert.Equal(t, expected.PublicID.String(), actual[consoleql.FieldPublicID])
assert.Equal(t, expected.Description, actual[consoleql.FieldDescription])
createdAt := time.Time{}
err := createdAt.UnmarshalText([]byte(actual[consoleql.FieldCreatedAt].(string)))
assert.NoError(t, err)
assert.True(t, expected.CreatedAt.Equal(createdAt))
assert.EqualValues(t, expectedNumMembers, actual[consoleql.FieldMemberCount])
}
var foundProj1, foundProj2 bool
for _, entry := range projectsList {
project := entry.(map[string]interface{})
id := project[consoleql.FieldID].(string)
switch id {
case createdProject.ID.String():
foundProj1 = true
testProject(t, project, createdProject, 3)
case project2.ID.String():
foundProj2 = true
testProject(t, project, project2, 1)
}
}
assert.True(t, foundProj1)
assert.True(t, foundProj2)
})
})
}

View File

@ -0,0 +1,64 @@
// Copyright (C) 2019 Storj Labs, Inc.
// See LICENSE for copying information.
package consoleql
import (
"github.com/graphql-go/graphql"
)
const (
// RewardType is a graphql type for reward.
RewardType = "reward"
// FieldAwardCreditInCent is a field name for award credit amount for referrers.
FieldAwardCreditInCent = "awardCreditInCent"
// FieldInviteeCreditInCents is a field name for credit amount rewarded to invitees.
FieldInviteeCreditInCents = "inviteeCreditInCents"
// FieldRedeemableCap is a field name for the total redeemable amount of the reward offer.
FieldRedeemableCap = "redeemableCap"
// FieldAwardCreditDurationDays is a field name for the valid time frame of current award credit.
FieldAwardCreditDurationDays = "awardCreditDurationDays"
// FieldInviteeCreditDurationDays is a field name for the valid time frame of current invitee credit.
FieldInviteeCreditDurationDays = "inviteeCreditDurationDays"
// FieldExpiresAt is a field name for the expiration time of a reward offer.
FieldExpiresAt = "expiresAt"
// FieldType is a field name for the type of reward offers.
FieldType = "type"
// FieldStatus is a field name for the status of reward offers.
FieldStatus = "status"
)
func graphqlReward() *graphql.Object {
return graphql.NewObject(graphql.ObjectConfig{
Name: RewardType,
Fields: graphql.Fields{
FieldID: &graphql.Field{
Type: graphql.Int,
},
FieldAwardCreditInCent: &graphql.Field{
Type: graphql.Int,
},
FieldInviteeCreditInCents: &graphql.Field{
Type: graphql.Int,
},
FieldRedeemableCap: &graphql.Field{
Type: graphql.Int,
},
FieldAwardCreditDurationDays: &graphql.Field{
Type: graphql.Int,
},
FieldInviteeCreditDurationDays: &graphql.Field{
Type: graphql.Int,
},
FieldType: &graphql.Field{
Type: graphql.Int,
},
FieldStatus: &graphql.Field{
Type: graphql.Int,
},
FieldExpiresAt: &graphql.Field{
Type: graphql.String,
},
},
})
}

View File

@ -0,0 +1,27 @@
// Copyright (C) 2018 Storj Labs, Inc.
// See LICENSE for copying information.
package consoleql
import (
"github.com/graphql-go/graphql"
"go.uber.org/zap"
"storj.io/storj/satellite/console"
"storj.io/storj/satellite/mailservice"
)
// CreateSchema creates a schema for satellites console graphql api.
func CreateSchema(log *zap.Logger, service *console.Service, mailService *mailservice.Service) (schema graphql.Schema, err error) {
creator := TypeCreator{}
err = creator.Create(log, service, mailService)
if err != nil {
return
}
return graphql.NewSchema(graphql.SchemaConfig{
Query: creator.RootQuery(),
Mutation: creator.RootMutation(),
})
}

View File

@ -0,0 +1,612 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional //EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><!--[if IE]><html xmlns="http://www.w3.org/1999/xhtml" class="ie"><![endif]--><!--[if !IE]><!--><html style="margin: 0;padding: 0;" xmlns="http://www.w3.org/1999/xhtml"><!--<![endif]--><head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title></title>
<!--[if !mso]><!--><meta http-equiv="X-UA-Compatible" content="IE=edge" /><!--<![endif]-->
<meta name="viewport" content="width=device-width" /><style type="text/css">
@media only screen and (min-width: 620px){.wrapper{min-width:600px !important}.wrapper h1{}.wrapper h1{font-size:64px !important;line-height:63px !important}.wrapper h2{}.wrapper h2{font-size:30px !important;line-height:38px !important}.wrapper h3{}.wrapper h3{font-size:22px !important;line-height:31px !important}.column{}.wrapper .size-8{font-size:8px !important;line-height:14px !important}.wrapper .size-9{font-size:9px !important;line-height:16px !important}.wrapper .size-10{font-size:10px !important;line-height:18px !important}.wrapper .size-11{font-size:11px !important;line-height:19px !important}.wrapper .size-12{font-size:12px !important;line-height:19px !important}.wrapper .size-13{font-size:13px !important;line-height:21px !important}.wrapper .size-14{font-size:14px !important;line-height:21px !important}.wrapper .size-15{font-size:15px !important;line-height:23px
!important}.wrapper .size-16{font-size:16px !important;line-height:24px !important}.wrapper .size-17{font-size:17px !important;line-height:26px !important}.wrapper .size-18{font-size:18px !important;line-height:26px !important}.wrapper .size-20{font-size:20px !important;line-height:28px !important}.wrapper .size-22{font-size:22px !important;line-height:31px !important}.wrapper .size-24{font-size:24px !important;line-height:32px !important}.wrapper .size-26{font-size:26px !important;line-height:34px !important}.wrapper .size-28{font-size:28px !important;line-height:36px !important}.wrapper .size-30{font-size:30px !important;line-height:38px !important}.wrapper .size-32{font-size:32px !important;line-height:40px !important}.wrapper .size-34{font-size:34px !important;line-height:43px !important}.wrapper .size-36{font-size:36px !important;line-height:43px !important}.wrapper
.size-40{font-size:40px !important;line-height:47px !important}.wrapper .size-44{font-size:44px !important;line-height:50px !important}.wrapper .size-48{font-size:48px !important;line-height:54px !important}.wrapper .size-56{font-size:56px !important;line-height:60px !important}.wrapper .size-64{font-size:64px !important;line-height:63px !important}}
</style>
<style type="text/css">
body {
margin: 0;
padding: 0;
}
table {
border-collapse: collapse;
table-layout: fixed;
}
* {
line-height: inherit;
}
[x-apple-data-detectors],
[href^="tel"],
[href^="sms"] {
color: inherit !important;
text-decoration: none !important;
}
.wrapper .footer__share-button a:hover,
.wrapper .footer__share-button a:focus {
color: #ffffff !important;
}
.btn a:hover,
.btn a:focus,
.footer__share-button a:hover,
.footer__share-button a:focus,
.email-footer__links a:hover,
.email-footer__links a:focus {
opacity: 0.8;
}
.preheader,
.header,
.layout,
.column {
transition: width 0.25s ease-in-out, max-width 0.25s ease-in-out;
}
.preheader td {
padding-bottom: 8px;
}
.layout,
div.header {
max-width: 400px !important;
-fallback-width: 95% !important;
width: calc(100% - 20px) !important;
}
div.preheader {
max-width: 360px !important;
-fallback-width: 90% !important;
width: calc(100% - 60px) !important;
}
.snippet,
.webversion {
Float: none !important;
}
.column {
max-width: 400px !important;
width: 100% !important;
}
.fixed-width.has-border {
max-width: 402px !important;
}
.fixed-width.has-border .layout__inner {
box-sizing: border-box;
}
.snippet,
.webversion {
width: 50% !important;
}
.ie .btn {
width: 100%;
}
[owa] .column div,
[owa] .column button {
display: block !important;
}
.ie .column,
[owa] .column,
.ie .gutter,
[owa] .gutter {
display: table-cell;
float: none !important;
vertical-align: top;
}
.ie div.preheader,
[owa] div.preheader,
.ie .email-footer,
[owa] .email-footer {
max-width: 560px !important;
width: 560px !important;
}
.ie .snippet,
[owa] .snippet,
.ie .webversion,
[owa] .webversion {
width: 280px !important;
}
.ie div.header,
[owa] div.header,
.ie .layout,
[owa] .layout,
.ie .one-col .column,
[owa] .one-col .column {
max-width: 600px !important;
width: 600px !important;
}
.ie .fixed-width.has-border,
[owa] .fixed-width.has-border,
.ie .has-gutter.has-border,
[owa] .has-gutter.has-border {
max-width: 602px !important;
width: 602px !important;
}
.ie .two-col .column,
[owa] .two-col .column {
max-width: 300px !important;
width: 300px !important;
}
.ie .three-col .column,
[owa] .three-col .column,
.ie .narrow,
[owa] .narrow {
max-width: 200px !important;
width: 200px !important;
}
.ie .wide,
[owa] .wide {
width: 400px !important;
}
.ie .two-col.has-gutter .column,
[owa] .two-col.x_has-gutter .column {
max-width: 290px !important;
width: 290px !important;
}
.ie .three-col.has-gutter .column,
[owa] .three-col.x_has-gutter .column,
.ie .has-gutter .narrow,
[owa] .has-gutter .narrow {
max-width: 188px !important;
width: 188px !important;
}
.ie .has-gutter .wide,
[owa] .has-gutter .wide {
max-width: 394px !important;
width: 394px !important;
}
.ie .two-col.has-gutter.has-border .column,
[owa] .two-col.x_has-gutter.x_has-border .column {
max-width: 292px !important;
width: 292px !important;
}
.ie .three-col.has-gutter.has-border .column,
[owa] .three-col.x_has-gutter.x_has-border .column,
.ie .has-gutter.has-border .narrow,
[owa] .has-gutter.x_has-border .narrow {
max-width: 190px !important;
width: 190px !important;
}
.ie .has-gutter.has-border .wide,
[owa] .has-gutter.x_has-border .wide {
max-width: 396px !important;
width: 396px !important;
}
.ie .fixed-width .layout__inner {
border-left: 0 none white !important;
border-right: 0 none white !important;
}
.ie .layout__edges {
display: none;
}
.mso .layout__edges {
font-size: 0;
}
.layout-fixed-width,
.mso .layout-full-width {
background-color: #ffffff;
}
@media only screen and (min-width: 620px) {
.column,
.gutter {
display: table-cell;
Float: none !important;
vertical-align: top;
}
div.preheader,
.email-footer {
max-width: 560px !important;
width: 560px !important;
}
.snippet,
.webversion {
width: 280px !important;
}
div.header,
.layout,
.one-col .column {
max-width: 600px !important;
width: 600px !important;
}
.fixed-width.has-border,
.fixed-width.ecxhas-border,
.has-gutter.has-border,
.has-gutter.ecxhas-border {
max-width: 602px !important;
width: 602px !important;
}
.two-col .column {
max-width: 300px !important;
width: 300px !important;
}
.three-col .column,
.column.narrow {
max-width: 200px !important;
width: 200px !important;
}
.column.wide {
width: 400px !important;
}
.two-col.has-gutter .column,
.two-col.ecxhas-gutter .column {
max-width: 290px !important;
width: 290px !important;
}
.three-col.has-gutter .column,
.three-col.ecxhas-gutter .column,
.has-gutter .narrow {
max-width: 188px !important;
width: 188px !important;
}
.has-gutter .wide {
max-width: 394px !important;
width: 394px !important;
}
.two-col.has-gutter.has-border .column,
.two-col.ecxhas-gutter.ecxhas-border .column {
max-width: 292px !important;
width: 292px !important;
}
.three-col.has-gutter.has-border .column,
.three-col.ecxhas-gutter.ecxhas-border .column,
.has-gutter.has-border .narrow,
.has-gutter.ecxhas-border .narrow {
max-width: 190px !important;
width: 190px !important;
}
.has-gutter.has-border .wide,
.has-gutter.ecxhas-border .wide {
max-width: 396px !important;
width: 396px !important;
}
}
@media (max-width: 321px) {
.fixed-width.has-border .layout__inner {
border-width: 1px 0 !important;
}
.layout,
.column {
min-width: 320px !important;
width: 320px !important;
}
.border {
display: none;
}
}
.mso div {
border: 0 none white !important;
}
.mso .w560 .divider {
Margin-left: 260px !important;
Margin-right: 260px !important;
}
.mso .w360 .divider {
Margin-left: 160px !important;
Margin-right: 160px !important;
}
.mso .w260 .divider {
Margin-left: 110px !important;
Margin-right: 110px !important;
}
.mso .w160 .divider {
Margin-left: 60px !important;
Margin-right: 60px !important;
}
.mso .w354 .divider {
Margin-left: 157px !important;
Margin-right: 157px !important;
}
.mso .w250 .divider {
Margin-left: 105px !important;
Margin-right: 105px !important;
}
.mso .w148 .divider {
Margin-left: 54px !important;
Margin-right: 54px !important;
}
.mso .size-8,
.ie .size-8 {
font-size: 8px !important;
line-height: 14px !important;
}
.mso .size-9,
.ie .size-9 {
font-size: 9px !important;
line-height: 16px !important;
}
.mso .size-10,
.ie .size-10 {
font-size: 10px !important;
line-height: 18px !important;
}
.mso .size-11,
.ie .size-11 {
font-size: 11px !important;
line-height: 19px !important;
}
.mso .size-12,
.ie .size-12 {
font-size: 12px !important;
line-height: 19px !important;
}
.mso .size-13,
.ie .size-13 {
font-size: 13px !important;
line-height: 21px !important;
}
.mso .size-14,
.ie .size-14 {
font-size: 14px !important;
line-height: 21px !important;
}
.mso .size-15,
.ie .size-15 {
font-size: 15px !important;
line-height: 23px !important;
}
.mso .size-16,
.ie .size-16 {
font-size: 16px !important;
line-height: 24px !important;
}
.mso .size-17,
.ie .size-17 {
font-size: 17px !important;
line-height: 26px !important;
}
.mso .size-18,
.ie .size-18 {
font-size: 18px !important;
line-height: 26px !important;
}
.mso .size-20,
.ie .size-20 {
font-size: 20px !important;
line-height: 28px !important;
}
.mso .size-22,
.ie .size-22 {
font-size: 22px !important;
line-height: 31px !important;
}
.mso .size-24,
.ie .size-24 {
font-size: 24px !important;
line-height: 32px !important;
}
.mso .size-26,
.ie .size-26 {
font-size: 26px !important;
line-height: 34px !important;
}
.mso .size-28,
.ie .size-28 {
font-size: 28px !important;
line-height: 36px !important;
}
.mso .size-30,
.ie .size-30 {
font-size: 30px !important;
line-height: 38px !important;
}
.mso .size-32,
.ie .size-32 {
font-size: 32px !important;
line-height: 40px !important;
}
.mso .size-34,
.ie .size-34 {
font-size: 34px !important;
line-height: 43px !important;
}
.mso .size-36,
.ie .size-36 {
font-size: 36px !important;
line-height: 43px !important;
}
.mso .size-40,
.ie .size-40 {
font-size: 40px !important;
line-height: 47px !important;
}
.mso .size-44,
.ie .size-44 {
font-size: 44px !important;
line-height: 50px !important;
}
.mso .size-48,
.ie .size-48 {
font-size: 48px !important;
line-height: 54px !important;
}
.mso .size-56,
.ie .size-56 {
font-size: 56px !important;
line-height: 60px !important;
}
.mso .size-64,
.ie .size-64 {
font-size: 64px !important;
line-height: 63px !important;
}
</style>
<!--[if !mso]><!--><style type="text/css">
@import url(https://fonts.googleapis.com/css?family=Montserrat:400,700,400italic);
</style><link href="https://fonts.googleapis.com/css?family=Montserrat:400,700,400italic" rel="stylesheet" type="text/css" /><!--<![endif]--><style type="text/css">
body{background-color:#fff}.logo a:hover,.logo a:focus{color:#859bb1 !important}.mso .layout-has-border{border-top:1px solid #ccc;border-bottom:1px solid #ccc}.mso .layout-has-bottom-border{border-bottom:1px solid #ccc}.mso .border,.ie .border{background-color:#ccc}.mso h1,.ie h1{}.mso h1,.ie h1{font-size:64px !important;line-height:63px !important}.mso h2,.ie h2{}.mso h2,.ie h2{font-size:30px !important;line-height:38px !important}.mso h3,.ie h3{}.mso h3,.ie h3{font-size:22px !important;line-height:31px !important}.mso .layout__inner,.ie .layout__inner{}.mso .footer__share-button p{}.mso .footer__share-button p{font-family:sans-serif}
</style><meta name="robots" content="noindex,nofollow" />
<meta property="og:title" content="My First Campaign" />
</head>
<!--[if mso]>
<body class="mso">
<![endif]-->
<!--[if !mso]><!-->
<body class="half-padding" style="margin: 0;padding: 0;-webkit-text-size-adjust: 100%;">
<!--<![endif]-->
<table class="wrapper" style="border-collapse: collapse;table-layout: fixed;min-width: 320px;width: 100%;background-color: #fff;" cellpadding="0" cellspacing="0" role="presentation"><tbody><tr><td>
<div role="banner">
<div class="preheader" style="Margin: 0 auto;max-width: 560px;min-width: 280px; width: 280px;width: calc(28000% - 167440px);">
<div style="border-collapse: collapse;display: table;width: 100%;">
</div>
</div>
<div class="header" style="Margin: 0 auto;max-width: 600px;min-width: 320px; width: 320px;width: calc(28000% - 167400px);" id="emb-email-header-container">
<!--[if (mso)|(IE)]><table align="center" class="header" cellpadding="0" cellspacing="0" role="presentation"><tr><td style="width: 600px"><![endif]-->
<div class="logo emb-logo-margin-box" style="font-size: 26px;line-height: 32px;Margin-top: 20px;Margin-bottom: 24px;color: #c3ced9;font-family: Roboto,Tahoma,sans-serif;Margin-left: 20px;Margin-right: 20px;" align="center">
<div class="logo-left" align="left" id="emb-email-header">
<img src="../images/emails/Logo.png" alt="Company logo"/>
</div>
</div>
<!--[if (mso)|(IE)]></td></tr></table><![endif]-->
</div>
</div>
<div role="section">
<div class="layout one-col fixed-width" style="Margin: 0 auto;max-width: 600px;min-width: 320px; width: 320px;width: calc(28000% - 167400px);overflow-wrap: break-word;word-wrap: break-word;word-break: break-word;">
<div class="layout__inner" style="border-collapse: collapse;display: table;width: 100%;background-color: #fff;">
<!--[if (mso)|(IE)]><table align="center" cellpadding="0" cellspacing="0" role="presentation"><tr class="layout-fixed-width" style="background-color: #fff;"><td style="width: 600px" class="w560"><![endif]-->
<div class="column" style="text-align: left;color: #8e959c;font-size: 14px;line-height: 21px;font-family: sans-serif;max-width: 600px;min-width: 320px; width: 320px;width: calc(28000% - 167400px);">
<div style="Margin-left: 20px;Margin-right: 20px;Margin-top: 12px;Margin-bottom: 12px;">
<div style="mso-line-height-rule: exactly;mso-text-raise: 4px;">
<h1 class="size-40" style="Margin-top: 0;Margin-bottom: 0;font-style: normal;font-weight: normal;color: #000;font-size: 32px;line-height: 40px;font-family: montserrat,dejavu sans,verdana,sans-serif;" lang="x-size-40"><span class="font-montserrat"><strong>Hi {{ .UserName }},</strong></span></h1>
</div>
</div>
</div>
<!--[if (mso)|(IE)]></td></tr></table><![endif]-->
</div>
</div>
<div style="mso-line-height-rule: exactly;line-height: 20px;font-size: 20px;">&nbsp;</div>
<div class="layout one-col fixed-width" style="Margin: 0 auto;max-width: 600px;min-width: 320px; width: 320px;width: calc(28000% - 167400px);overflow-wrap: break-word;word-wrap: break-word;word-break: break-word;">
<div class="layout__inner" style="border-collapse: collapse;display: table;width: 100%;background-color: #fff;">
<!--[if (mso)|(IE)]><table align="center" cellpadding="0" cellspacing="0" role="presentation"><tr class="layout-fixed-width" style="background-color: #fff;"><td style="width: 600px" class="w560"><![endif]-->
<div class="column" style="text-align: left;color: #8e959c;font-size: 14px;line-height: 21px;font-family: sans-serif;max-width: 600px;min-width: 320px; width: 320px;width: calc(28000% - 167400px);">
<div style="Margin-left: 20px;Margin-right: 20px;Margin-top: 12px;Margin-bottom: 12px;">
<div style="mso-line-height-rule: exactly;mso-text-raise: 4px;">
<p class="size-20" style="Margin-top: 0;Margin-bottom: 0;font-family: montserrat,dejavu sans,verdana,sans-serif;font-size: 17px;line-height: 26px;" lang="x-size-20"><span class="font-montserrat">You were invited to the <a href="https://storj.io" style="color: #2683ff; text-decoration: none; font-weight: bold">{{ .ProjectName }}</a> on Satellite network</span></p><p class="size-20" style="Margin-top: 5px;Margin-bottom: 0;font-family: montserrat,dejavu sans,verdana,sans-serif;font-size: 17px;line-height: 26px;" lang="x-size-20"><span class="font-montserrat">&#8232;Now you can login and see who is already in your team! &#8232;</span></p>
</div>
</div>
</div>
<!--[if (mso)|(IE)]></td></tr></table><![endif]-->
</div>
</div>
<div style="mso-line-height-rule: exactly;line-height: 20px;font-size: 20px;">&nbsp;</div>
<div class="layout one-col fixed-width" style="Margin: 0 auto;max-width: 600px;min-width: 320px; width: 320px;width: calc(28000% - 167400px);overflow-wrap: break-word;word-wrap: break-word;word-break: break-word;">
<div class="layout__inner" style="border-collapse: collapse;display: table;width: 100%;background-color: #fff;">
<!--[if (mso)|(IE)]><table align="center" cellpadding="0" cellspacing="0" role="presentation"><tr class="layout-fixed-width" style="background-color: #fff;"><td style="width: 600px" class="w560"><![endif]-->
<div class="column" style="text-align: left;color: #8e959c;font-size: 14px;line-height: 21px;font-family: sans-serif;max-width: 600px;min-width: 320px; width: 320px;width: calc(28000% - 167400px);">
<div style="Margin-left: 20px;Margin-right: 20px;Margin-top: 12px;Margin-bottom: 12px;">
<div class="btn btn--flat btn--large" style="text-align:left;">
<a style="border-radius: 4px;display: inline-block;font-size: 14px;font-weight: bold;line-height: 24px;padding: 12px 50px;text-align: center;text-decoration: none !important;transition: opacity 0.1s ease-in;color: #ffffff !important;background-color: #2683ff;font-family: Montserrat, DejaVu Sans, Verdana, sans-serif;" href="https://storj.io/">Sign In</a>
<!--[if mso]><p style="line-height:0;margin:0;">&nbsp;</p><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" href="https://storj.io/" style="width:191px" arcsize="9%" fillcolor="#2683FF" stroke="f"><v:textbox style="mso-fit-shape-to-text:t" inset="0px,11px,0px,11px"><center style="font-size:14px;line-height:24px;color:#FFFFFF;font-family:Montserrat,DejaVu Sans,Verdana,sans-serif;font-weight:bold;mso-line-height-rule:exactly;mso-text-raise:4px">Sign In</center></v:textbox></v:roundrect><![endif]--></div>
</div>
</div>
<!--[if (mso)|(IE)]></td></tr></table><![endif]-->
</div>
</div>
<div style="mso-line-height-rule: exactly;line-height: 20px;font-size: 20px;">&nbsp;</div>
<div class="layout one-col fixed-width" style="Margin: 0 auto;max-width: 600px;min-width: 320px; width: 320px;width: calc(28000% - 167400px);overflow-wrap: break-word;word-wrap: break-word;word-break: break-word;">
<div class="layout__inner" style="border-collapse: collapse;display: table;width: 100%;background-color: #fff;">
<!--[if (mso)|(IE)]><table align="center" cellpadding="0" cellspacing="0" role="presentation"><tr class="layout-fixed-width" style="background-color: #fff;"><td style="width: 600px" class="w560"><![endif]-->
<div class="column" style="text-align: left;color: #8e959c;font-size: 14px;line-height: 21px;font-family: sans-serif;max-width: 600px;min-width: 320px; width: 320px;width: calc(28000% - 167400px);">
<div style="Margin-left: 20px;Margin-right: 20px;Margin-top: 12px;">
<div class="divider" style="display: block;font-size: 2px;line-height: 1px;Margin-left: auto;Margin-right: auto;width: 100%;background-color: #ccc;Margin-bottom: 20px;">&nbsp;</div>
</div>
</div>
<!--[if (mso)|(IE)]></td></tr></table><![endif]-->
</div>
</div>
<div style="mso-line-height-rule: exactly;line-height: 20px;font-size: 20px;">&nbsp;</div>
<div class="layout one-col fixed-width" style="Margin: 0 auto;max-width: 600px;min-width: 320px; width: 320px;width: calc(28000% - 167400px);overflow-wrap: break-word;word-wrap: break-word;word-break: break-word;">
<div class="layout__inner" style="border-collapse: collapse;display: table;width: 100%;background-color: #fff;">
<!--[if (mso)|(IE)]><table align="center" cellpadding="0" cellspacing="0" role="presentation"><tr class="layout-fixed-width" style="background-color: #fff;"><td style="width: 600px" class="w560"><![endif]-->
<div class="column" style="text-align: left;color: #8e959c;font-size: 14px;line-height: 21px;font-family: sans-serif;max-width: 600px;min-width: 320px; width: 320px;width: calc(28000% - 167400px);">
<div style="Margin-left: 20px;Margin-right: 20px;Margin-top: 12px;Margin-bottom: 12px;">
<div style="mso-line-height-rule: exactly;mso-text-raise: 4px;">
<p class="size-12" style="Margin-top: 0;Margin-bottom: 0;font-family: montserrat,dejavu sans,verdana,sans-serif;font-size: 12px;line-height: 19px;" lang="x-size-12"><span class="font-montserrat">Please do not reply to this email.<br />
3423 Piedmont Road NE, Suite 475, Atlanta, Georgia, 30305, United States</span></p>
</div>
</div>
</div>
<!--[if (mso)|(IE)]></td></tr></table><![endif]-->
</div>
</div>
<div style="mso-line-height-rule: exactly;line-height: 20px;font-size: 20px;">&nbsp;</div>
<div class="layout three-col fixed-width" style="Margin: 0 auto;max-width: 600px;min-width: 320px; width: 320px;width: calc(28000% - 167400px);overflow-wrap: break-word;word-wrap: break-word;word-break: break-word;">
<div class="layout__inner" style="border-collapse: collapse;display: table;width: 100%;background-color: #fff;">
<!--[if (mso)|(IE)]><table align="center" cellpadding="0" cellspacing="0" role="presentation"><tr class="layout-fixed-width" style="background-color: #fff;"><td style="width: 200px" valign="top" class="w160"><![endif]-->
<div class="column" style="text-align: left;color: #8e959c;font-size: 14px;line-height: 21px;font-family: sans-serif;Float: left;max-width: 320px;min-width: 200px; width: 320px;width: calc(72200px - 12000%);">
<div style="Margin-left: 20px;Margin-right: 20px;Margin-top: 0px;Margin-bottom: 0px;">
<div style="mso-line-height-rule: exactly;mso-text-raise: 4px;">
<a href="https://storj.io/" style="text-decoration: none; color: #66686C;">
<p href="https://storj.io/" class="size-12" style="Margin-top: 0;Margin-bottom: 0;font-family: montserrat,dejavu sans,verdana,sans-serif;font-size: 12px;line-height: 19px;" lang="x-size-12"><span class="font-montserrat"><strong>Help</strong></span></p>
</a>
</div>
</div>
</div>
<!--[if (mso)|(IE)]></td><td style="width: 200px" valign="top" class="w160"><![endif]-->
<div class="column" style="text-align: left;color: #8e959c;font-size: 14px;line-height: 21px;font-family: sans-serif;Float: left;max-width: 320px;min-width: 200px; width: 320px;width: calc(72200px - 12000%);">
<div style="Margin-left: 20px;Margin-right: 20px;Margin-top: 0px;Margin-bottom: 0px;">
<div style="mso-line-height-rule: exactly;mso-text-raise: 4px;">
<a href="https://storj.io/" style="text-decoration: none; color: #66686C;">
<p href="https://storj.io/" class="size-12" style="Margin-top: 0;Margin-bottom: 0;font-family: montserrat,dejavu sans,verdana,sans-serif;font-size: 12px;line-height: 19px;" lang="x-size-12"><span class="font-montserrat"><strong>Contact Info</strong></span></p>
</a>
</div>
</div>
</div>
<!--[if (mso)|(IE)]></td><td style="width: 100px" valign="top" class="w160"><![endif]-->
<div class="column" style="text-align: left;color: #8e959c;font-size: 14px;line-height: 21px;font-family: sans-serif;Float: left;max-width: 150px;min-width: 100px; width: 320px;width: calc(72200px - 12000%);">
<div style="Margin-left: 20px;Margin-right: 20px;Margin-top: 0px;Margin-bottom: 0px;">
<div style="mso-line-height-rule: exactly;mso-text-raise: 4px;">
<a href="https://storj.io/" style="text-decoration: none; color: #66686C;">
<p class="size-12" style="Margin-top: 0;Margin-bottom: 0;font-family: montserrat,dejavu sans,verdana,sans-serif;font-size: 12px;line-height: 19px;" lang="x-size-12"><span class="font-montserrat"><strong>Terms &amp; Conditions</strong><br />
&nbsp;</span></p>
</a>
</div>
</div>
</div>
<!--[if (mso)|(IE)]></td></tr></table><![endif]-->
</div>
</div>
<div class="layout one-col fixed-width" style="Margin: 0 auto;max-width: 600px;min-width: 320px; width: 320px;width: calc(28000% - 167400px);overflow-wrap: break-word;word-wrap: break-word;word-break: break-word;">
<div class="layout__inner" style="border-collapse: collapse;display: table;width: 100%;background-color: #fff;">
<!--[if (mso)|(IE)]><table align="center" cellpadding="0" cellspacing="0" role="presentation"><tr class="layout-fixed-width" style="background-color: #fff;"><td style="width: 600px" class="w560"><![endif]-->
<div class="column" style="text-align: left;color: #8e959c;font-size: 14px;line-height: 21px;font-family: sans-serif;max-width: 600px;min-width: 320px; width: 320px;width: calc(28000% - 167400px);">
<div style="Margin-left: 20px;Margin-right: 20px;Margin-top: 0px;Margin-bottom: 12px;">
<div style="mso-line-height-rule: exactly;mso-text-raise: 4px;">
<p class="size-10" style="Margin-top: 0;Margin-bottom: 0;font-family: montserrat,dejavu sans,verdana,sans-serif;font-size: 10px;line-height: 18px;" lang="x-size-10"><span class="font-montserrat">Storj Labs Inc 2019.<br />
&nbsp;</span></p>
</div>
</div>
</div>
<!--[if (mso)|(IE)]></td></tr></table><![endif]-->
</div>
</div>
</div></td></tr></tbody></table>
</body></html>

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,168 @@
// Copyright (C) 2019 Storj Labs, Inc.
// See LICENSE for copying information.
package consoleql
import (
"github.com/graphql-go/graphql"
"go.uber.org/zap"
"storj.io/storj/satellite/console"
"storj.io/storj/satellite/mailservice"
)
// TypeCreator handles graphql type creation and error checking.
type TypeCreator struct {
query *graphql.Object
mutation *graphql.Object
user *graphql.Object
reward *graphql.Object
project *graphql.Object
projectUsage *graphql.Object
projectsPage *graphql.Object
bucketUsage *graphql.Object
bucketUsagePage *graphql.Object
projectMember *graphql.Object
projectInvitation *graphql.Object
projectMembersAndInvitationsPage *graphql.Object
apiKeyPage *graphql.Object
apiKeyInfo *graphql.Object
createAPIKey *graphql.Object
userInput *graphql.InputObject
projectInput *graphql.InputObject
projectLimit *graphql.InputObject
projectsCursor *graphql.InputObject
bucketUsageCursor *graphql.InputObject
projectMembersCursor *graphql.InputObject
apiKeysCursor *graphql.InputObject
}
// Create create types and check for error.
func (c *TypeCreator) Create(log *zap.Logger, service *console.Service, mailService *mailservice.Service) error {
// inputs
c.userInput = graphqlUserInput()
if err := c.userInput.Error(); err != nil {
return err
}
c.projectInput = graphqlProjectInput()
if err := c.projectInput.Error(); err != nil {
return err
}
c.projectLimit = graphqlProjectLimit()
if err := c.projectLimit.Error(); err != nil {
return err
}
c.bucketUsageCursor = graphqlBucketUsageCursor()
if err := c.bucketUsageCursor.Error(); err != nil {
return err
}
c.projectMembersCursor = graphqlProjectMembersCursor()
if err := c.projectMembersCursor.Error(); err != nil {
return err
}
c.apiKeysCursor = graphqlAPIKeysCursor()
if err := c.apiKeysCursor.Error(); err != nil {
return err
}
// entities
c.user = graphqlUser()
if err := c.user.Error(); err != nil {
return err
}
c.reward = graphqlReward()
if err := c.reward.Error(); err != nil {
return err
}
c.projectUsage = graphqlProjectUsage()
if err := c.projectUsage.Error(); err != nil {
return err
}
c.bucketUsage = graphqlBucketUsage()
if err := c.bucketUsage.Error(); err != nil {
return err
}
c.bucketUsagePage = graphqlBucketUsagePage(c)
if err := c.bucketUsagePage.Error(); err != nil {
return err
}
c.apiKeyInfo = graphqlAPIKeyInfo()
if err := c.apiKeyInfo.Error(); err != nil {
return err
}
c.createAPIKey = graphqlCreateAPIKey(c)
if err := c.createAPIKey.Error(); err != nil {
return err
}
c.projectMember = graphqlProjectMember(service, c)
if err := c.projectMember.Error(); err != nil {
return err
}
c.projectInvitation = graphqlProjectInvitation()
if err := c.projectInvitation.Error(); err != nil {
return err
}
c.projectMembersAndInvitationsPage = graphqlProjectMembersAndInvitationsPage(c)
if err := c.projectMembersAndInvitationsPage.Error(); err != nil {
return err
}
c.apiKeyPage = graphqlAPIKeysPage(c)
if err := c.apiKeyPage.Error(); err != nil {
return err
}
c.project = graphqlProject(service, c)
if err := c.project.Error(); err != nil {
return err
}
c.projectsCursor = graphqlProjectsCursor()
if err := c.projectsCursor.Error(); err != nil {
return err
}
c.projectsPage = graphqlProjectsPage(c)
if err := c.projectsPage.Error(); err != nil {
return err
}
// root objects
c.query = rootQuery(service, mailService, c)
if err := c.query.Error(); err != nil {
return err
}
c.mutation = rootMutation(log, service, mailService, c)
if err := c.mutation.Error(); err != nil {
return err
}
return nil
}
// RootQuery returns instance of query *graphql.Object.
func (c *TypeCreator) RootQuery() *graphql.Object {
return c.query
}
// RootMutation returns instance of mutation *graphql.Object.
func (c *TypeCreator) RootMutation() *graphql.Object {
return c.mutation
}

View File

@ -0,0 +1,78 @@
// Copyright (C) 2019 Storj Labs, Inc.
// See LICENSE for copying information.
package consoleql
import (
"github.com/graphql-go/graphql"
)
const (
// UserType is a graphql type for user.
UserType = "user"
// UserInputType is a graphql type for user input.
UserInputType = "userInput"
// FieldID is a field name for id.
FieldID = "id"
// FieldEmail is a field name for email.
FieldEmail = "email"
// FieldPassword is a field name for password.
FieldPassword = "password"
// FieldFullName is a field name for "first name".
FieldFullName = "fullName"
// FieldShortName is a field name for "last name".
FieldShortName = "shortName"
// FieldCreatedAt is a field name for created at timestamp.
FieldCreatedAt = "createdAt"
)
// base graphql config for user.
func baseUserConfig() graphql.ObjectConfig {
return graphql.ObjectConfig{
Name: UserType,
Fields: graphql.Fields{
FieldID: &graphql.Field{
Type: graphql.String,
},
FieldEmail: &graphql.Field{
Type: graphql.String,
},
FieldFullName: &graphql.Field{
Type: graphql.String,
},
FieldShortName: &graphql.Field{
Type: graphql.String,
},
FieldCreatedAt: &graphql.Field{
Type: graphql.DateTime,
},
},
}
}
// graphqlUser creates *graphql.Object type representation of satellite.User.
func graphqlUser() *graphql.Object {
// TODO: simplify
return graphql.NewObject(baseUserConfig())
}
// graphqlUserInput creates graphql.InputObject type needed to register/update satellite.User.
func graphqlUserInput() *graphql.InputObject {
return graphql.NewInputObject(graphql.InputObjectConfig{
Name: UserInputType,
Fields: graphql.InputObjectConfigFieldMap{
FieldEmail: &graphql.InputObjectFieldConfig{
Type: graphql.String,
},
FieldFullName: &graphql.InputObjectFieldConfig{
Type: graphql.String,
},
FieldShortName: &graphql.InputObjectFieldConfig{
Type: graphql.String,
},
FieldPassword: &graphql.InputObjectFieldConfig{
Type: graphql.String,
},
},
})
}

View File

@ -376,6 +376,66 @@ func TestAPIKeys(t *testing.T) {
user := test.defaultUser()
test.login(user.email, user.password)
{ // Post_GenerateApiKey
resp, body := test.request(http.MethodPost, "/graphql",
test.toJSON(map[string]interface{}{
"variables": map[string]interface{}{
"projectId": test.defaultProjectID(),
"name": user.email,
},
"query": `
mutation ($projectId: String!, $name: String!) {
createAPIKey(projectID: $projectId, name: $name) {
key
keyInfo {
id
name
createdAt
__typename
}
__typename
}
}`}))
require.Contains(t, body, "createAPIKey")
require.Equal(t, http.StatusOK, resp.StatusCode)
}
{ // Get_APIKeyInfoByProjectId
resp, body := test.request(http.MethodPost, "/graphql",
test.toJSON(map[string]interface{}{
"variables": map[string]interface{}{
"orderDirection": 1,
"projectId": test.defaultProjectID(),
"limit": 6,
"search": ``,
"page": 1,
"order": 1,
},
"query": `
query ($projectId: String!, $limit: Int!, $search: String!, $page: Int!, $order: Int!, $orderDirection: Int!) {
project(id: $projectId) {
apiKeys(cursor: {limit: $limit, search: $search, page: $page, order: $order, orderDirection: $orderDirection}) {
apiKeys {
id
name
createdAt
__typename
}
search
limit
order
pageCount
currentPage
totalCount
__typename
}
__typename
}
}`}))
require.Contains(t, body, "apiKeysPage")
require.Equal(t, http.StatusOK, resp.StatusCode)
}
{ // Get_ProjectAPIKeys
var projects console.APIKeyPage
path := "/api-keys/list-paged?projectID=" + test.defaultProjectID() + "&search=''&limit=6&page=1&order=1&orderDirection=1"
@ -429,6 +489,24 @@ func TestProjects(t *testing.T) {
user := test.defaultUser()
test.login(user.email, user.password)
{ // Get_ProjectId
resp, body := test.request(http.MethodPost, "/graphql",
test.toJSON(map[string]interface{}{
"query": `
{
myProjects {
name
id
description
createdAt
ownerId
__typename
}
}`}))
require.Contains(t, body, test.defaultProjectID())
require.Equal(t, http.StatusOK, resp.StatusCode)
}
{ // Get_Salt
projectID := test.defaultProjectID()
id, err := uuid.FromString(projectID)
@ -469,6 +547,45 @@ func TestProjects(t *testing.T) {
require.NotEmpty(t, projects)
}
{ // Get_ProjectInfo
resp, body := test.request(http.MethodPost, "/graphql",
test.toJSON(map[string]interface{}{
"variables": map[string]interface{}{
"projectId": test.defaultProjectID(),
"before": "2021-05-12T18:32:30.533Z",
"limit": 7,
"search": "",
"page": 1,
},
"query": `
query ($projectId: String!, $before: DateTime!, $limit: Int!, $search: String!, $page: Int!) {
project(id: $projectId) {
bucketUsages(before: $before, cursor: {limit: $limit, search: $search, page: $page}) {
bucketUsages {
bucketName
storage
egress
objectCount
segmentCount
since
before
__typename
}
search
limit
offset
pageCount
currentPage
totalCount
__typename
}
__typename
}
}`}))
require.Contains(t, body, "bucketUsagePage")
require.Equal(t, http.StatusOK, resp.StatusCode)
}
{ // Get_ProjectUsageLimitById
resp, body := test.request(http.MethodGet, `/projects/`+test.defaultProjectID()+`/usage-limits`, nil)
require.Equal(t, http.StatusOK, resp.StatusCode)

View File

@ -24,6 +24,8 @@ import (
"time"
"github.com/gorilla/mux"
"github.com/graphql-go/graphql"
"github.com/graphql-go/graphql/gqlerrors"
"github.com/spacemonkeygo/monkit/v3"
"github.com/zeebo/errs"
"go.uber.org/zap"
@ -39,6 +41,7 @@ import (
"storj.io/storj/satellite/analytics"
"storj.io/storj/satellite/console"
"storj.io/storj/satellite/console/consoleweb/consoleapi"
"storj.io/storj/satellite/console/consoleweb/consoleql"
"storj.io/storj/satellite/console/consoleweb/consolewebauth"
"storj.io/storj/satellite/mailservice"
"storj.io/storj/satellite/oidc"
@ -48,7 +51,8 @@ import (
const (
contentType = "Content-Type"
applicationJSON = "application/json"
applicationJSON = "application/json"
applicationGraphql = "application/graphql"
)
var (
@ -148,6 +152,8 @@ type Server struct {
packagePlans paymentsconfig.PackagePlans
schema graphql.Schema
errorTemplate *template.Template
}
@ -263,6 +269,8 @@ func NewServer(logger *zap.Logger, config Config, service *console.Service, oidc
router.Handle("/api/v0/config", server.withCORS(http.HandlerFunc(server.frontendConfigHandler)))
router.Handle("/api/v0/graphql", server.withCORS(server.withAuth(http.HandlerFunc(server.graphqlHandler))))
router.HandleFunc("/registrationToken/", server.createRegistrationTokenHandler)
router.HandleFunc("/robots.txt", server.seoHandler)
@ -413,6 +421,11 @@ func NewServer(logger *zap.Logger, config Config, service *console.Service, oidc
func (server *Server) Run(ctx context.Context) (err error) {
defer mon.Task()(&ctx)(&err)
server.schema, err = consoleql.CreateSchema(server.log, server.service, server.mailService)
if err != nil {
return Error.Wrap(err)
}
_, err = server.loadErrorTemplate()
if err != nil {
return Error.Wrap(err)
@ -924,6 +937,130 @@ func (server *Server) handleInvited(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, server.config.ExternalAddress+"signup?"+params.Encode(), http.StatusTemporaryRedirect)
}
// graphqlHandler is graphql endpoint http handler function.
func (server *Server) graphqlHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
defer mon.Task()(&ctx)(nil)
handleError := func(code int, err error) {
w.WriteHeader(code)
var jsonError struct {
Error string `json:"error"`
RequestID string `json:"requestID"`
}
jsonError.Error = err.Error()
if requestID := requestid.FromContext(ctx); requestID != "" {
jsonError.RequestID = requestID
}
if err := json.NewEncoder(w).Encode(jsonError); err != nil {
server.log.Error("error graphql error", zap.Error(err))
}
}
w.Header().Set(contentType, applicationJSON)
query, err := getQuery(w, r)
if err != nil {
handleError(http.StatusBadRequest, err)
return
}
rootObject := make(map[string]interface{})
rootObject["origin"] = server.config.ExternalAddress
rootObject[consoleql.ActivationPath] = "activation?token="
rootObject[consoleql.PasswordRecoveryPath] = "password-recovery?token="
rootObject[consoleql.CancelPasswordRecoveryPath] = "cancel-password-recovery?token="
rootObject[consoleql.SignInPath] = "login"
rootObject[consoleql.LetUsKnowURL] = server.config.LetUsKnowURL
rootObject[consoleql.ContactInfoURL] = server.config.ContactInfoURL
rootObject[consoleql.TermsAndConditionsURL] = server.config.TermsAndConditionsURL
rootObject[consoleql.SatelliteRegion] = server.config.SatelliteName
result := graphql.Do(graphql.Params{
Schema: server.schema,
Context: ctx,
RequestString: query.Query,
VariableValues: query.Variables,
OperationName: query.OperationName,
RootObject: rootObject,
})
getGqlError := func(err gqlerrors.FormattedError) error {
var gerr *gqlerrors.Error
if errors.As(err.OriginalError(), &gerr) {
return gerr.OriginalError
}
return nil
}
parseConsoleError := func(err error) (int, error) {
switch {
case console.ErrUnauthorized.Has(err):
return http.StatusUnauthorized, err
case console.Error.Has(err):
return http.StatusInternalServerError, err
}
return 0, nil
}
handleErrors := func(code int, errors gqlerrors.FormattedErrors) {
w.WriteHeader(code)
var jsonError struct {
Errors []string `json:"errors"`
RequestID string `json:"requestID"`
}
for _, err := range errors {
jsonError.Errors = append(jsonError.Errors, err.Message)
}
if requestID := requestid.FromContext(ctx); requestID != "" {
jsonError.RequestID = requestID
}
if err := json.NewEncoder(w).Encode(jsonError); err != nil {
server.log.Error("error graphql error", zap.Error(err))
}
}
handleGraphqlErrors := func() {
for _, err := range result.Errors {
gqlErr := getGqlError(err)
if gqlErr == nil {
continue
}
code, err := parseConsoleError(gqlErr)
if err != nil {
handleError(code, err)
return
}
}
handleErrors(http.StatusOK, result.Errors)
}
if result.HasErrors() {
handleGraphqlErrors()
return
}
err = json.NewEncoder(w).Encode(result)
if err != nil {
server.log.Error("error encoding grapql result", zap.Error(err))
return
}
server.log.Debug(fmt.Sprintf("%s", result))
}
// serveError serves a static error page.
func (server *Server) serveError(w http.ResponseWriter, status int) {
w.WriteHeader(status)

View File

@ -4,13 +4,18 @@
package consoleweb
import (
"encoding/json"
"fmt"
"io"
"mime"
"net/http"
"regexp"
"strings"
"github.com/zeebo/errs"
"storj.io/common/memory"
"storj.io/storj/satellite/console/consoleweb/consoleql"
)
// ContentLengthLimit describes 4KB limit.
@ -28,6 +33,42 @@ func init() {
}
}
// JSON request from graphql clients.
type graphqlJSON struct {
Query string
OperationName string
Variables map[string]interface{}
}
// getQuery retrieves graphql query from request.
func getQuery(w http.ResponseWriter, req *http.Request) (query graphqlJSON, err error) {
switch req.Method {
case http.MethodGet:
query.Query = req.URL.Query().Get(consoleql.Query)
return query, nil
case http.MethodPost:
return queryPOST(w, req)
default:
return query, errs.New("wrong http request type")
}
}
// queryPOST retrieves graphql query from POST request.
func queryPOST(w http.ResponseWriter, req *http.Request) (query graphqlJSON, err error) {
limitedReader := http.MaxBytesReader(w, req.Body, ContentLengthLimit.Int64())
switch typ := req.Header.Get(contentType); typ {
case applicationGraphql:
body, err := io.ReadAll(limitedReader)
query.Query = string(body)
return query, errs.Combine(err, limitedReader.Close())
case applicationJSON:
err := json.NewDecoder(limitedReader).Decode(&query)
return query, errs.Combine(err, limitedReader.Close())
default:
return query, errs.New("can't parse request body of type %s", typ)
}
}
// getClientIPRegExp is used by the function getClientIP.
var getClientIPRegExp = regexp.MustCompile(`(?i:(?:^|;)for=([^,; ]+))`)

View File

@ -56,6 +56,7 @@ require (
github.com/gorilla/schema v1.2.0 // indirect
github.com/gorilla/websocket v1.4.2 // indirect
github.com/graph-gophers/graphql-go v1.3.0 // indirect
github.com/graphql-go/graphql v0.7.9 // indirect
github.com/hashicorp/go-bexpr v0.1.10 // indirect
github.com/hashicorp/golang-lru v0.5.5-0.20210104140557-80c98217689d // indirect
github.com/hashicorp/hcl v1.0.0 // indirect

View File

@ -320,6 +320,8 @@ github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad
github.com/graph-gophers/graphql-go v0.0.0-20201113091052-beb923fada29/go.mod h1:9CQHMSxwO4MprSdzoIEobiHpoLtHm77vfxsvsIN5Vuc=
github.com/graph-gophers/graphql-go v1.3.0 h1:Eb9x/q6MFpCLz7jBCiP/WTxjSDrYLR1QY41SORZyNJ0=
github.com/graph-gophers/graphql-go v1.3.0/go.mod h1:9CQHMSxwO4MprSdzoIEobiHpoLtHm77vfxsvsIN5Vuc=
github.com/graphql-go/graphql v0.7.9 h1:5Va/Rt4l5g3YjwDnid3vFfn43faaQBq7rMcIZ0VnV34=
github.com/graphql-go/graphql v0.7.9/go.mod h1:k6yrAYQaSP59DC5UVxbgxESlmVyojThKdORUqGDGmrI=
github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=

View File

@ -81,6 +81,7 @@ require (
github.com/gorilla/mux v1.8.0 // indirect
github.com/gorilla/schema v1.2.0 // indirect
github.com/gorilla/websocket v1.4.2 // indirect
github.com/graphql-go/graphql v0.7.9 // indirect
github.com/hashicorp/go-uuid v1.0.2 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.0.0 // indirect

View File

@ -570,6 +570,8 @@ github.com/gorilla/schema v1.2.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlI
github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/graphql-go/graphql v0.7.9 h1:5Va/Rt4l5g3YjwDnid3vFfn43faaQBq7rMcIZ0VnV34=
github.com/graphql-go/graphql v0.7.9/go.mod h1:k6yrAYQaSP59DC5UVxbgxESlmVyojThKdORUqGDGmrI=
github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=